diff --git a/.eslintrc b/.eslintrc index 3a9f159f0ccfe92fd1144a65223fd64a936d120f..513a6f9464437c8aaba793c6a7086f5912819d3e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "parser": "babel-eslint", "rules": { "no-undef": 2, - "no-unused-vars": [2, {"vars": "all", "args": "none", "varsIgnorePattern": "React|PropTypes|Component"}], + "no-unused-vars": [1, {"vars": "all", "args": "none", "varsIgnorePattern": "React|PropTypes|Component"}], "quotes": 0, "camelcase": 0, "eqeqeq": 0, diff --git a/resources/frontend_client/app/app.js b/resources/frontend_client/app/app.js index 9bf4deebd0aeb2bc70e5517f9e6afea62bae1a4a..1275ecda87c980b3178794a8ef83f0f3649dea50 100644 --- a/resources/frontend_client/app/app.js +++ b/resources/frontend_client/app/app.js @@ -13,7 +13,6 @@ var Metabase = angular.module('metabase', [ 'metabase.components', 'metabase.card', 'metabase.dashboard', - 'metabase.explore', 'metabase.home', 'metabase.user', 'metabase.setup', diff --git a/resources/frontend_client/app/card/card.controllers.js b/resources/frontend_client/app/card/card.controllers.js index 15868e6c20c81441906555430625564984172f04..71672dcb4a3ba3e713f3070085f008019cee5c6f 100644 --- a/resources/frontend_client/app/card/card.controllers.js +++ b/resources/frontend_client/app/card/card.controllers.js @@ -4,6 +4,7 @@ import _ from "underscore"; import MetabaseAnalytics from '../lib/analytics'; import DataGrid from "metabase/lib/data_grid"; +import { addValidOperatorsToFields } from "metabase/lib/schema_metadata"; import DataReference from '../query_builder/DataReference.react'; import GuiQueryEditor from '../query_builder/GuiQueryEditor.react'; @@ -73,8 +74,8 @@ CardControllers.controller('CardList', ['$scope', '$location', 'Card', function( }]); CardControllers.controller('CardDetail', [ - '$rootScope', '$scope', '$route', '$routeParams', '$location', '$q', '$window', '$timeout', 'Card', 'Dashboard', 'MetabaseFormGenerator', 'Metabase', 'VisualizationSettings', 'QueryUtils', 'Revision', - function($rootScope, $scope, $route, $routeParams, $location, $q, $window, $timeout, Card, Dashboard, MetabaseFormGenerator, Metabase, VisualizationSettings, QueryUtils, Revision) { + '$rootScope', '$scope', '$route', '$routeParams', '$location', '$q', '$window', '$timeout', 'Card', 'Dashboard', 'Metabase', 'VisualizationSettings', 'QueryUtils', 'Revision', + function($rootScope, $scope, $route, $routeParams, $location, $q, $window, $timeout, Card, Dashboard, Metabase, VisualizationSettings, QueryUtils, Revision) { // promise helper $q.resolve = function(object) { var deferred = $q.defer(); @@ -188,7 +189,6 @@ CardControllers.controller('CardDetail', [ isRunning: false, isShowingDataReference: null, databases: null, - tables: null, tableMetadata: null, tableForeignKeys: null, query: null, @@ -377,7 +377,6 @@ CardControllers.controller('CardDetail', [ editorModel.isRunning = isRunning; editorModel.isShowingDataReference = $scope.isShowingDataReference; editorModel.databases = databases; - editorModel.tables = tables; editorModel.tableMetadata = tableMetadata; editorModel.tableForeignKeys = tableForeignKeys; editorModel.query = card.dataset_query; @@ -696,7 +695,7 @@ CardControllers.controller('CardDetail', [ } function markupTableMetadata(table) { - var updatedTable = MetabaseFormGenerator.addValidOperatorsToFields(table); + var updatedTable = addValidOperatorsToFields(table); return QueryUtils.populateQueryOptions(updatedTable); } @@ -931,7 +930,15 @@ CardControllers.controller('CardDetail', [ // TODO: while we wait for the databases list we should put something on screen // grab our database list, then handle the rest - Metabase.db_list(function (dbs) { + async function loadDatabasesAndTables() { + let dbs = await Metabase.db_list().$promise; + return await * dbs.map(async function(db) { + db.tables = await Metabase.db_tables({ dbId: db.id }).$promise; + return db; + }); + } + + loadDatabasesAndTables().then(function(dbs) { databases = dbs; if (dbs.length < 1) { diff --git a/resources/frontend_client/app/components/CheckBox.react.js b/resources/frontend_client/app/components/CheckBox.react.js new file mode 100644 index 0000000000000000000000000000000000000000..c91797fe8e078eb2f8d4afd36939816af44f37b7 --- /dev/null +++ b/resources/frontend_client/app/components/CheckBox.react.js @@ -0,0 +1,36 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +import Icon from 'metabase/components/Icon.react'; + +export default class CheckBox extends Component { + onClick() { + if (this.props.onChange) { + // TODO: use a proper event object? + this.props.onChange({ target: { checked: !this.props.checked }}) + } + } + + render() { + const { checked } = this.props; + const style = { + width: '1rem', + height: '1rem', + border: '2px solid #ddd', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }; + return ( + <div style={style} onClick={() => this.onClick()}> + { checked ? <Icon name='check' width={10} height={10} /> : null } + </div> + ) + } +} + +CheckBox.propTypes = { + checked: PropTypes.bool, + onChange: PropTypes.func +}; diff --git a/resources/frontend_client/app/components/buttons/buttons.css b/resources/frontend_client/app/components/buttons/buttons.css index a7b2306244c065c0d2b77363f4f4c46a36cd444d..859415ee783cafaf6dd3b308c2b2b78cf9ffd384 100644 --- a/resources/frontend_client/app/components/buttons/buttons.css +++ b/resources/frontend_client/app/components/buttons/buttons.css @@ -47,6 +47,15 @@ font-size: 0.6rem; } +.Button--medium { + padding: 0.5rem 1rem; + font-size: 0.8rem; +} + +.Button-normal { + font-weight: normal; +} + .Button--primary { color: #fff; background: var(--primary-button-bg-color); @@ -69,6 +78,12 @@ border-color: color(var(--base-grey) shade(30%)); } +.Button--purple { + color: white; + background-color: #A989C5; + border: 1px solid #885AB1; +} + .Button-group { display: inline-block; border-radius: var(--default-button-border-radius); diff --git a/resources/frontend_client/app/components/calendar/calendar.css b/resources/frontend_client/app/components/calendar/calendar.css index d428fc462db3d50d6031abdbb72409559b87d0be..96e2e0e942b56f451c9e5fb51838a35de57445a6 100644 --- a/resources/frontend_client/app/components/calendar/calendar.css +++ b/resources/frontend_client/app/components/calendar/calendar.css @@ -6,14 +6,25 @@ display: flex; } -.Calendar-day { +.Calendar-day, +.Calendar-day-name { flex: 1; - padding: 0.75em; - margin: 0.25em; - text-align: center; +} + +.Calendar-day { color: color(var(--base-grey) shade(30%)); - border-radius: 99px; - cursor: pointer; + position: relative; + border: 1px solid color(var(--base-grey) shade(20%) alpha(-50%)); + border-radius: 0; + border-bottom-width: 0; + border-right-width: 0; +} + +.Calendar-day:last-child { + border-right-width: 1px; +} +.Calendar-week:last-child .Calendar-day { + border-bottom-width: 1px; } .Calendar-day-name { @@ -36,7 +47,49 @@ color: inherit !important; } -.Calendar-day--selected { +.Calendar-day--selected, +.Calendar-day--selected-end { color: white !important; background-color: var(--purple-light-color); } + +.Calendar-day--selected, +.Calendar-day--selected-end { + background-color: var(--purple-color); +} + +.Calendar-day--in-range { + background-color: #E3DAEB; +} + +.Calendar-day--selected:after, +.Calendar-day--selected-end:after, +.Calendar-day--in-range:after { + content: ""; + position: absolute; + top: -2px; + bottom: -1px; + left: -2px; + right: -2px; + border: 2px solid color(var(--purple-color) shade(25%)); + border-radius: 4px; + z-index: 2; +} + +.Calendar-day--in-range:after { + border-left-color: transparent; + border-right-color: transparent; + border-radius: 0px; +} + +.Calendar-day--week-start.Calendar-day--in-range:after { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-left-color: color(var(--purple-color) shade(25%)); +} + +.Calendar-day--week-end.Calendar-day--in-range:after { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + border-right-color: color(var(--purple-color) shade(25%)); +} diff --git a/resources/frontend_client/app/css/core/bordered.css b/resources/frontend_client/app/css/core/bordered.css index 2eca0bb90e2499f53e8f9f6f059a2751d57c2418..e239bca0522a7519bdfb68929d4be1e9c72d1263 100644 --- a/resources/frontend_client/app/css/core/bordered.css +++ b/resources/frontend_client/app/css/core/bordered.css @@ -62,6 +62,18 @@ border-color: rgba(0,0,0,0.2) !important; } +.border-purple { + border-color: var(--purple-color) !important; +} + +.border-error { + border-color: var(--error-color) !important; +} + +.border-hover:hover { + border-color: color(var(--border-color) shade(20%)); +} + /* BORDERLESS IS THE DEFAULT */ /* ONLY USE IF needing to override an existing border! */ /* ensure there is no border via important */ diff --git a/resources/frontend_client/app/css/core/colors.css b/resources/frontend_client/app/css/core/colors.css index 64ca98b49da5af1af11dd1d0370e1465c2d6376f..699c3f86493b6e3d72a3d3f5d69e60dd59cb1fc8 100644 --- a/resources/frontend_client/app/css/core/colors.css +++ b/resources/frontend_client/app/css/core/colors.css @@ -98,6 +98,7 @@ .bg-gold { background-color: var(--gold-color); } .bg-purple { background-color: var(--purple-color); } +.bg-purple-light { background-color: var(--purple-light-color); } .bg-green { background-color: var(--green-color); } /* alt */ diff --git a/resources/frontend_client/app/css/core/text.css b/resources/frontend_client/app/css/core/text.css index ba135c7a48e1899587bdc2cde16bbee3da8be539..45ec9be051b2f79410b0715a4dd411097d7d1f05 100644 --- a/resources/frontend_client/app/css/core/text.css +++ b/resources/frontend_client/app/css/core/text.css @@ -98,3 +98,7 @@ .text-current { color: currentColor; } + +.text-underline { + text-decoration: underline;; +} diff --git a/resources/frontend_client/app/css/query_builder.css b/resources/frontend_client/app/css/query_builder.css index 28af20e554ae06b58b54e2c025529146d65fea9e..eb90b6360ed225e3859864a1ae52eb65ec6ec70f 100644 --- a/resources/frontend_client/app/css/query_builder.css +++ b/resources/frontend_client/app/css/query_builder.css @@ -76,13 +76,13 @@ overflow-x: scroll; max-width: 400px; white-space: nowrap; + height: 55px; } .Query-filter { display: flex; align-items: center; font-size: 0.75em; - height: 56px; border: 2px solid transparent; border-radius: var(--default-border-radius); } @@ -285,7 +285,7 @@ .GuiBuilder-section { position: relative; - min-height: 48px; + min-height: 55px; min-width: 120px; } @@ -323,30 +323,30 @@ /* FILTER BY SECTION */ .Filter-section-field, -.Filter-section-operator, -.Filter-section-value { +.Filter-section-operator { color: var(--purple-color); } -.Filter-section-field.selected .QueryOption { +.Filter-section-field .QueryOption { color: var(--purple-color); } -.Filter-section-operator.selected .QueryOption { +.Filter-section-operator .QueryOption { color: var(--purple-color); text-transform: lowercase; } -.Filter-section-value.selected .QueryOption { - color: var(--purple-color); +.Filter-section-value .QueryOption { + color: white; + background-color: var(--purple-color); + border: 1px solid color(var(--purple-color) shade(30%)); + border-radius: 6px; + padding: 0.5em; + padding-top: 0.3em; + padding-bottom: 0.3em; + margin-bottom: 0.2em; } -/* put quotes around numeric or text values */ -.Filter-section-value .QueryOption.QueryOption--text:before, -.Filter-section-value .QueryOption.QueryOption--text:after, -.Filter-section-value .QueryOption.QueryOption--number:before, -.Filter-section-value .QueryOption.QueryOption--number:after, -.Filter-section-value .QueryOption.QueryOption--select:before, -.Filter-section-value .QueryOption.QueryOption--select:after { - content: '"'; +.Filter-section-value { + padding-right: 0.5em; } .Filter-section-sort-field.selected .QueryOption, @@ -354,10 +354,6 @@ color: inherit; } -.Filter-section-value { - padding-right: 0.5em; -} - .FilterPopover .ColumnarSelector-row--selected, .FilterPopover .PopoverHeader-item.selected { color: var(--purple-color) !important; @@ -366,23 +362,6 @@ background-color: var(--purple-color) !important; } -.Filter-section-value .Button, -.Filter-section-value .input { - padding: 0.5rem; -} - -.Filter-section-value .input { - font-size: inherit; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.Filter-section-value .input:focus { - outline: none; - border-color: var(--purple-color); - box-shadow: 0 0 2px var(--purple-color); -} - /* VIEW SECTION */ .View-section-aggregation, @@ -562,3 +541,54 @@ font-weight: bold; border-right: 1px solid color(var(--base-grey) shade(40%)); } + +.List-item { + display: flex; + border-radius: 6px; + border: 2px solid transparent; +} + +.List-section-header .Icon, +.List-item .Icon { + color: #ddd; +} + +.List-item:hover, +.List-item--selected { + background-color: currentColor; + border-color: rgba(0,0,0,0.2); + /*color: white;*/ +} + +.List-item-title { + color: var(--default-font-color); +} + +.List-item:hover .List-item-title, +.List-item--selected .List-item-title { + color: white; +} + +.List-section-header { + color: var(--default-font-color); +} + +.List-section-header:hover { + color: currentColor; +} + +.List-item:hover .Icon, +.List-item--selected .Icon { + color: white; +} + +.FieldList-grouping-trigger { + display: none; +} + +.List-item:hover .FieldList-grouping-trigger, +.List-item--selected .FieldList-grouping-trigger { + display: flex; + border-left: 2px solid rgba(0,0,0,0.1); + color: rgba(255,255,255,0.5); +} diff --git a/resources/frontend_client/app/explore/explore.module.js b/resources/frontend_client/app/explore/explore.module.js deleted file mode 100644 index 3888abf40479a82934fee552aea482ea899a0333..0000000000000000000000000000000000000000 --- a/resources/frontend_client/app/explore/explore.module.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict'; - -// Explore (Metabase) -angular.module('metabase.explore', [ - 'metabase.explore.services' -]); diff --git a/resources/frontend_client/app/explore/explore.services.js b/resources/frontend_client/app/explore/explore.services.js deleted file mode 100644 index bc0545df5da526351a81c1b398e277c7ea0c5fcb..0000000000000000000000000000000000000000 --- a/resources/frontend_client/app/explore/explore.services.js +++ /dev/null @@ -1,329 +0,0 @@ -'use strict'; - -import _ from "underscore"; - -import SchemaMetadata from "metabase/lib/schema_metadata"; - - -var ExploreServices = angular.module('metabase.explore.services', []); - -ExploreServices.service('MetabaseFormGenerator', [function() { - // Valid Operators per field - - function isDate(field) { - return SchemaMetadata.isDateType(field); - } - - function isNumber(field) { - return SchemaMetadata.isNumericType(field); - } - - function isSummable(field) { - return SchemaMetadata.isSummableType(field); - } - - function isCategory(field) { - return SchemaMetadata.isCategoryType(field); - } - - function isDimension(field) { - return SchemaMetadata.isDimension(field); - } - - - function freeformArgument(field, table) { - return { - 'type': "text" - }; - } - - - function numberArgument(field, table) { - return { - 'type': "number" - }; - } - - - function comparableArgument(field, table) { - - var inputType = "text"; - if (isNumber(field)) { - inputType = "number"; - } - if (isDate(field)) { - inputType = "date"; - } - return { - 'type': inputType - }; - } - - - function equivalentArgument(field, table) { - - var input_type = "text"; - if (isDate(field)) { - input_type = "date"; - } - if (isNumber(field)) { - input_type = "number"; - } - - if (isCategory(field)) { - // DON'T UNDERSTAND WHY I HAVE TO DO THIS (!) - if (!table.field_values) { - table.field_values = {}; - for (var fld in table.fields) { - table.field_values[fld.id] = fld.display_name; // ??? - } - } - - if (field.id in table.field_values && table.field_values[field.id].length > 0) { - var valid_values = table.field_values[field.id]; - valid_values.sort(); - return { - "type": "select", - 'values': _.map(valid_values, function(value) { - return { - 'key': value, - 'name': value - }; - }) - }; - } - } - return { - 'type': input_type - }; - } - - function longitudeFieldSelectArgument(field, table) { - var longitudeFields = _.filter(table.fields, function(field) { - return field.special_type == "longitude"; - }); - var validValues = _.map(longitudeFields, function(field) { - return { - 'key': field.id, - 'name': field.display_name - }; - }); - - return { - "values": validValues, - "type": "select" - }; - } - - var FilterOperators = { - 'IS': { - 'name': "=", - 'verbose_name': "Is", - 'validArgumentsFilters': [equivalentArgument] - }, - 'IS_NOT': { - 'name': "!=", - 'verbose_name': "Is Not", - 'validArgumentsFilters': [equivalentArgument] - }, - 'IS_NULL': { - 'name': "IS_NULL", - 'verbose_name': "Is Null", - 'validArgumentsFilters': [] - }, - 'IS_NOT_NULL': { - 'name': "NOT_NULL", - 'verbose_name': "Is Not Null", - 'validArgumentsFilters': [] - }, - 'LESS_THAN': { - 'name': "<", - 'verbose_name': "Less Than", - 'validArgumentsFilters': [comparableArgument] - }, - 'LESS_THAN_OR_EQUAL': { - 'name': "<=", - 'verbose_name': "Less Than or Equal To", - 'validArgumentsFilters': [comparableArgument] - }, - 'GREATER_THAN': { - 'name': ">", - 'verbose_name': "Greater Than", - 'validArgumentsFilters': [comparableArgument] - }, - 'GREATER_THAN_OR_EQUAL': { - 'name': ">=", - 'verbose_name': "Greater Than or Equal To", - 'validArgumentsFilters': [comparableArgument] - }, - 'INSIDE': { - 'name': "INSIDE", - 'verbose_name': "Inside - (Lat,Long) for upper left, (Lat,Long) for lower right", - 'validArgumentsFilters': [longitudeFieldSelectArgument, numberArgument, numberArgument, numberArgument, numberArgument] - }, - 'BETWEEN': { - 'name': "BETWEEN", - 'verbose_name': "Between - Min, Max", - 'validArgumentsFilters': [comparableArgument, comparableArgument] - }, - 'STARTS_WITH': { - 'name': "STARTS_WITH", - 'verbose_name': "Starts With", - 'validArgumentsFilters': [freeformArgument] - }, - 'ENDS_WITH': { - 'name': "ENDS_WITH", - 'verbose_name': "Ends With", - 'validArgumentsFilters': [freeformArgument] - }, - 'CONTAINS': { - 'name': "CONTAINS", - 'verbose_name': "Contains", - 'validArgumentsFilters': [freeformArgument] - } - }; - - var BaseOperators = ['IS', 'IS_NOT', 'IS_NULL', 'IS_NOT_NULL']; - - var AdditionalOperators = { - 'CharField': ['STARTS_WITH', 'ENDS_WITH', 'CONTAINS'], - 'TextField': ['STARTS_WITH', 'ENDS_WITH', 'CONTAINS'], - 'IntegerField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'BigIntegerField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'DecimalField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'FloatField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'DateTimeField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'DateField': ['LESS_THAN', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'BETWEEN'], - 'LatLongField': ['INSIDE'], - 'latitude': ['INSIDE'] - }; - - function formatOperator(cls, field, table) { - return { - 'name': cls.name, - 'verbose_name': cls.verbose_name, - 'validArgumentsFilters': cls.validArgumentsFilters, - 'fields': _.map(cls.validArgumentsFilters, function(validArgumentsFilter) { - return validArgumentsFilter(field, table); - }) - }; - } - - function getOperators(field, table) { - // All fields have the base operators - var validOperators = BaseOperators; - - // Check to see if the field's base type offers additional operators - if (field.base_type in AdditionalOperators) { - validOperators = validOperators.concat(AdditionalOperators[field.base_type]); - } - // Check to see if the field's semantic type offers additional operators - if (field.special_type in AdditionalOperators) { - validOperators = validOperators.concat(AdditionalOperators[field.special_type]); - } - // Wrap them up and send them back - return _.map(validOperators, function(operator) { - return formatOperator(FilterOperators[operator], field, table); - }); - } - - // Breakouts and Aggregation options - function allFields(fields) { - return fields; - } - - function summableFields(fields) { - return _.filter(fields, isSummable); - } - - function dimensionFields(fields) { - return _.filter(fields, isDimension); - } - - var Aggregators = [{ - "name": "Raw data", - "short": "rows", - "description": "Just a table with the rows in the answer, no additional operations.", - "advanced": false, - "validFieldsFilters": [] - }, { - "name": "Count", - "short": "count", - "description": "Total number of rows in the answer.", - "advanced": false, - "validFieldsFilters": [] - }, { - "name": "Sum", - "short": "sum", - "description": "Sum of all the values of a column.", - "advanced": false, - "validFieldsFilters": [summableFields] - }, { - "name": "Average", - "short": "avg", - "description": "Average of all the values of a column", - "advanced": false, - "validFieldsFilters": [summableFields] - }, { - "name": "Number of distinct values", - "short": "distinct", - "description": "Number of unique values of a column among all the rows in the answer.", - "advanced": true, - "validFieldsFilters": [allFields] - }, { - "name": "Cumulative sum", - "short": "cum_sum", - "description": "Additive sum of all the values of a column.\ne.x. total revenue over time.", - "advanced": true, - "validFieldsFilters": [summableFields] - }, { - "name": "Standard deviation", - "short": "stddev", - "description": "Number which expresses how much the values of a colum vary among all rows in the answer.", - "advanced": true, - "validFieldsFilters": [summableFields] - }]; - - var BreakoutAggregator = { - "name": "Break out by dimension", - "short": "breakout", - "validFieldsFilters": [dimensionFields] - }; - - function populateFields(aggregator, fields) { - return { - 'name': aggregator.name, - 'short': aggregator.short, - 'description': aggregator.description || '', - 'advanced': aggregator.advanced || false, - 'fields': _.map(aggregator.validFieldsFilters, function(validFieldsFilterFn) { - return validFieldsFilterFn(fields); - }), - 'validFieldsFilters': aggregator.validFieldsFilters - }; - } - - function getAggregators(fields) { - return _.map(Aggregators, function(aggregator) { - return populateFields(aggregator, fields); - }); - } - - function getBreakouts(fields) { - var result = populateFields(BreakoutAggregator, fields); - result.fields = result.fields[0]; - result.validFieldsFilter = result.validFieldsFilters[0]; - return result; - } - - // Main entry function - this.addValidOperatorsToFields = function(table) { - _.each(table.fields, function(field) { - field.valid_operators = getOperators(field, table); - }); - table.aggregation_options = getAggregators(table.fields); - table.breakout_options = getBreakouts(table.fields); - return table; - }; - -}]); diff --git a/resources/frontend_client/app/icon_paths.js b/resources/frontend_client/app/icon_paths.js index c970ec20c7be276e44b49a441d83ad1c761478e6..cac939dc6e64fe1912957567a843dcb51cffd44d 100644 --- a/resources/frontend_client/app/icon_paths.js +++ b/resources/frontend_client/app/icon_paths.js @@ -20,6 +20,10 @@ export var ICON_PATHS = { area: 'M25.4980562,23.9977382 L26.0040287,23.9999997 L26.0040283,22.4903505 L26.0040283,14 L26.0040287,12 L25.3213548,13.2692765 C25.3213548,13.2692765 22.6224921,15.7906709 21.2730607,17.0513681 C21.1953121,17.1240042 15.841225,18.0149981 15.841225,18.0149981 L15.5173319,18.0717346 L15.2903187,18.3096229 L10.5815987,23.2439142 L9.978413,23.9239006 L11.3005782,23.9342813 L25.4980562,23.9977382 L11.3050484,23.9342913 L16.0137684,19 L21.7224883,18 L26.0040283,14 L26.0040283,23.4903505 C26.0040283,23.7718221 25.7731425,23.9989679 25.4980562,23.9977382 Z M7,23.9342913 L14,16 L21,14 L25.6441509,9.35958767 C25.8429057,9.16099288 26.0040283,9.22974944 26.0040283,9.49379817 L26.0040283,13 L26.0040283,24 L7,23.9342913 Z', bar: 'M9,20 L12,20 L12,24 L9,24 L9,20 Z M14,14 L17,14 L17,24 L14,24 L14,14 Z M19,9 L22,9 L22,24 L19,24 L19,9 Z', cards: 'M16.5,11 C16.1340991,11 15.7865579,10.9213927 15.4733425,10.7801443 L7.35245972,21.8211652 C7.7548404,22.264891 8,22.8538155 8,23.5 C8,24.8807119 6.88071187,26 5.5,26 C4.11928813,26 3,24.8807119 3,23.5 C3,22.1192881 4.11928813,21 5.5,21 C5.87370843,21 6.22826528,21.0819977 6.5466604,21.2289829 L14.6623495,10.1950233 C14.2511829,9.74948188 14,9.15407439 14,8.5 C14,7.11928813 15.1192881,6 16.5,6 C17.8807119,6 19,7.11928813 19,8.5 C19,8.96980737 18.8704088,9.4093471 18.6450228,9.78482291 L25.0405495,15.4699905 C25.4512188,15.1742245 25.9552632,15 26.5,15 C27.8807119,15 29,16.1192881 29,17.5 C29,18.8807119 27.8807119,20 26.5,20 C25.1192881,20 24,18.8807119 24,17.5 C24,17.0256697 24.1320984,16.5821926 24.3615134,16.2043506 L17.9697647,10.5225413 C17.5572341,10.8228405 17.0493059,11 16.5,11 Z M5.5,25 C6.32842712,25 7,24.3284271 7,23.5 C7,22.6715729 6.32842712,22 5.5,22 C4.67157288,22 4,22.6715729 4,23.5 C4,24.3284271 4.67157288,25 5.5,25 Z M26.5,19 C27.3284271,19 28,18.3284271 28,17.5 C28,16.6715729 27.3284271,16 26.5,16 C25.6715729,16 25,16.6715729 25,17.5 C25,18.3284271 25.6715729,19 26.5,19 Z M16.5,10 C17.3284271,10 18,9.32842712 18,8.5 C18,7.67157288 17.3284271,7 16.5,7 C15.6715729,7 15,7.67157288 15,8.5 C15,9.32842712 15.6715729,10 16.5,10 Z', + calendar: { + path: 'M21,2 L21,0 L18,0 L18,2 L6,2 L6,0 L3,0 L3,2 L2.99109042,2 C1.34177063,2 0,3.34314575 0,5 L0,6.99502651 L0,20.009947 C0,22.2157067 1.78640758,24 3.99005301,24 L20.009947,24 C22.2157067,24 24,22.2135924 24,20.009947 L24,6.99502651 L24,5 C24,3.34651712 22.6608432,2 21.0089096,2 L21,2 L21,2 Z M22,8 L22,20.009947 C22,21.1099173 21.1102431,22 20.009947,22 L3.99005301,22 C2.89008272,22 2,21.1102431 2,20.009947 L2,8 L22,8 L22,8 Z M6,12 L10,12 L10,16 L6,16 L6,12 Z', + attrs: { viewBox: '0 0 24 24'} + }, check: 'M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z ', chevrondown: 'M1 12 L16 26 L31 12 L27 8 L16 18 L5 8 z ', chevronleft: 'M20 1 L24 5 L14 16 L24 27 L20 31 L6 16 z', @@ -50,8 +54,16 @@ export var ICON_PATHS = { gear: 'M14 0 H18 L19 6 L20.707 6.707 L26 3.293 L28.707 6 L25.293 11.293 L26 13 L32 14 V18 L26 19 L25.293 20.707 L28.707 26 L26 28.707 L20.707 25.293 L19 26 L18 32 L14 32 L13 26 L11.293 25.293 L6 28.707 L3.293 26 L6.707 20.707 L6 19 L0 18 L0 14 L6 13 L6.707 11.293 L3.293 6 L6 3.293 L11.293 6.707 L13 6 L14 0 z M16 10 A6 6 0 0 0 16 22 A6 6 0 0 0 16 10', grid: 'M2 2 L10 2 L10 10 L2 10z M12 2 L20 2 L20 10 L12 10z M22 2 L30 2 L30 10 L22 10z M2 12 L10 12 L10 20 L2 20z M12 12 L20 12 L20 20 L12 20z M22 12 L30 12 L30 20 L22 20z M2 22 L10 22 L10 30 L2 30z M12 22 L20 22 L20 30 L12 30z M22 22 L30 22 L30 30 L22 30z', history: 'M31.3510226,15.6624718 C31.3510226,23.4208104 25.0228069,29.7490261 17.2644683,29.7490261 C13.3633933,29.7490261 9.80936578,28.145223 7.2955414,25.5882133 L9.46263138,23.4208104 C11.4563542,25.3710349 14.2302293,26.6281036 17.2644683,26.6281036 C23.3326331,26.6281036 28.2301,21.7306367 28.2301,15.6624718 C28.2301,9.59430691 23.3326331,4.69652708 17.2644683,4.69652708 C11.9329575,4.69652708 7.4689086,8.38073652 6.47189074,13.4083853 L9.98273298,12.8882837 L4.99826955,19.8664699 L0.0573043855,12.8882837 L3.35128116,13.364887 C4.39148435,6.64706454 10.1995984,1.57591751 17.2644683,1.57591751 C25.0228069,1.57591751 31.3510226,7.86063493 31.3510226,15.6624718 Z M22.4222989,11.0679281 C24.2426545,10.2877757 25.4562249,13.1051492 23.6793675,13.9287998 L18.0449336,16.1825734 L16.8745485,19.3463683 C16.1810797,21.3400911 13.320521,20.04015 13.9704915,18.2629798 L15.3139308,14.5352721 C15.487298,14.1450394 15.7041635,13.8418033 16.0508979,13.7116214 L22.4222989,11.0679281 Z', + int: { + path: 'M15.141,15.512 L14.294,20 L13.051,20 C12.8309989,20 12.6403341,19.9120009 12.479,19.736 C12.3176659,19.5599991 12.237,19.343668 12.237,19.087 C12.237,19.0503332 12.2388333,19.0155002 12.2425,18.9825 C12.2461667,18.9494998 12.2516666,18.9146668 12.259,18.878 L12.908,15.512 L10.653,15.512 L10.015,19.01 C9.94899967,19.3620018 9.79866784,19.6149992 9.564,19.769 C9.32933216,19.9230008 9.06900143,20 8.783,20 L7.584,20 L8.42,15.512 L7.155,15.512 C6.92033216,15.512 6.74066729,15.4551672 6.616,15.3415 C6.49133271,15.2278328 6.429,15.0390013 6.429,14.775 C6.429,14.6723328 6.43999989,14.5550007 6.462,14.423 L6.605,13.554 L8.695,13.554 L9.267,10.518 L6.913,10.518 L7.122,9.385 C7.17333359,9.10633194 7.28699912,8.89916734 7.463,8.7635 C7.63900088,8.62783266 7.92499802,8.56 8.321,8.56 L9.542,8.56 L10.224,5.018 C10.282667,4.7246652 10.4183323,4.49733414 10.631,4.336 C10.8436677,4.17466586 11.0929986,4.094 11.379,4.094 L12.611,4.094 L11.775,8.56 L14.019,8.56 L14.866,4.094 L16.076,4.094 C16.3326679,4.094 16.5416659,4.1673326 16.703,4.314 C16.8643341,4.4606674 16.945,4.64766553 16.945,4.875 C16.945,4.9483337 16.9413334,5.00333315 16.934,5.04 L16.252,8.56 L18.485,8.56 L18.276,9.693 C18.2246664,9.97166806 18.1091676,10.1788327 17.9295,10.3145 C17.7498324,10.4501673 17.4656686,10.518 17.077,10.518 L15.977,10.518 L15.416,13.554 L16.978,13.554 C17.2126678,13.554 17.3904994,13.6108328 17.5115,13.7245 C17.6325006,13.8381672 17.693,14.0306653 17.693,14.302 C17.693,14.4046672 17.6820001,14.5219993 17.66,14.654 L17.528,15.512 L15.141,15.512 Z M10.928,13.554 L13.183,13.554 L13.744,10.518 L11.5,10.518 L10.928,13.554 Z', + attrs: { viewBox: '0 0 24, 24' } + }, line: 'M17.5684644,16.0668074 L15.9388754,14.3793187 L15.8968592,14.4198933 L15.8953638,14.4183447 L15.8994949,14.4142136 L15.6628229,14.1775415 L15.5851122,14.0970697 L15.5837075,14.0984261 L14.4852814,13 L7.56742615,19.9178552 L8.98163972,21.3320688 L14.4809348,15.8327737 L14.4809348,15.8327737 L16.1103863,17.52012 L16.1522861,17.4796579 L16.1522861,17.4796579 L16.1539209,17.4813508 L16.1500476,17.4852242 L16.3719504,17.707127 L16.4640332,17.8024814 L16.4656976,17.8008741 L17.5643756,18.8995521 L24.4820322,11.9818955 L23.0677042,10.5675676 L17.5684644,16.0668074 Z', list: 'M3 8 A3 3 0 0 0 9 8 A3 3 0 0 0 3 8 M12 6 L28 6 L28 10 L12 10z M3 16 A3 3 0 0 0 9 16 A3 3 0 0 0 3 16 M12 14 L28 14 L28 18 L12 18z M3 24 A3 3 0 0 0 9 24 A3 3 0 0 0 3 24 M12 22 L28 22 L28 26 L12 26z', + location: { + path: 'M19.4917776,13.9890373 C20.4445763,12.5611169 21,10.8454215 21,9 C21,4.02943725 16.9705627,0 12,0 C7.02943725,0 3,4.02943725 3,9 C3,10.8454215 3.5554237,12.5611168 4.50822232,13.9890371 L4.49999986,14.0000004 L4.58010869,14.0951296 C4.91305602,14.5790657 5.29212089,15.0288088 5.71096065,15.4380163 L12.5,23.5 L19.4999993,13.9999996 L19.4917776,13.9890373 L19.4917776,13.9890373 Z M12,12 C13.6568542,12 15,10.6568542 15,9 C15,7.34314575 13.6568542,6 12,6 C10.3431458,6 9,7.34314575 9,9 C9,10.6568542 10.3431458,12 12,12 Z', + attrs: { viewBox: '0 0 24 24' } + }, lock: 'M8.8125,13.2659641 L5.50307055,13.2659641 C4.93891776,13.2659641 4.5,13.7132101 4.5,14.2649158 L4.5,30.8472021 C4.5,31.4051918 4.94908998,31.8461538 5.50307055,31.8461538 L26.4969294,31.8461538 C27.0610822,31.8461538 27.5,31.3989079 27.5,30.8472021 L27.5,14.2649158 C27.5,13.7069262 27.05091,13.2659641 26.4969294,13.2659641 L23.1875,13.2659641 L23.1875,7.18200446 C23.1875,3.22368836 19.9695466,0 16,0 C12.0385306,0 8.8125,3.21549292 8.8125,7.18200446 L8.8125,13.2659641 Z M12.3509615,7.187641 C12.3509615,5.17225484 13.9813894,3.53846154 15.9955768,3.53846154 C18.0084423,3.53846154 19.6401921,5.17309313 19.6401921,7.187641 L19.6401921,13.0473232 L12.3509615,13.0473232 L12.3509615,7.187641 Z', mine: 'M28.4907419,50 C25.5584999,53.6578499 21.0527692,56 16,56 C10.9472308,56 6.44150015,53.6578499 3.50925809,50 L28.4907419,50 Z M29.8594823,31.9999955 C27.0930063,27.217587 21.922257,24 16,24 C10.077743,24 4.9069937,27.217587 2.1405177,31.9999955 L29.8594849,32 Z M16,21 C19.8659932,21 23,17.1944204 23,12.5 C23,7.80557963 22,3 16,3 C10,3 9,7.80557963 9,12.5 C9,17.1944204 12.1340068,21 16,21 Z', number: 'M8,8.4963932 C8,8.22224281 8.22618103,8 8.4963932,8 L23.5036068,8 C23.7777572,8 24,8.22618103 24,8.4963932 L24,23.5036068 C24,23.7777572 23.773819,24 23.5036068,24 L8.4963932,24 C8.22224281,24 8,23.773819 8,23.5036068 L8,8.4963932 Z M12.136,19 L12.136,13.4 L11.232,13.4 C11.1999998,13.6133344 11.1333338,13.7919993 11.032,13.936 C10.9306662,14.0800007 10.8066674,14.1959996 10.66,14.284 C10.5133326,14.3720004 10.3480009,14.4333332 10.164,14.468 C9.97999908,14.5026668 9.78933432,14.5173334 9.592,14.512 L9.592,15.368 L11,15.368 L11,19 L12.136,19 Z M13.616,16.176 C13.616,16.7360028 13.6706661,17.2039981 13.78,17.58 C13.8893339,17.9560019 14.0373324,18.2559989 14.224,18.48 C14.4106676,18.7040011 14.6279988,18.8639995 14.876,18.96 C15.1240012,19.0560005 15.3866653,19.104 15.664,19.104 C15.9466681,19.104 16.2119988,19.0560005 16.46,18.96 C16.7080012,18.8639995 16.9266657,18.7040011 17.116,18.48 C17.3053343,18.2559989 17.4546661,17.9560019 17.564,17.58 C17.6733339,17.2039981 17.728,16.7360028 17.728,16.176 C17.728,15.6319973 17.6733339,15.1746685 17.564,14.804 C17.4546661,14.4333315 17.3053343,14.1360011 17.116,13.912 C16.9266657,13.6879989 16.7080012,13.5280005 16.46,13.432 C16.2119988,13.3359995 15.9466681,13.288 15.664,13.288 C15.3866653,13.288 15.1240012,13.3359995 14.876,13.432 C14.6279988,13.5280005 14.4106676,13.6879989 14.224,13.912 C14.0373324,14.1360011 13.8893339,14.4333315 13.78,14.804 C13.6706661,15.1746685 13.616,15.6319973 13.616,16.176 Z M14.752,16.176 C14.752,16.0799995 14.7533333,15.9640007 14.756,15.828 C14.7586667,15.6919993 14.7679999,15.5520007 14.784,15.408 C14.8000001,15.2639993 14.8266665,15.121334 14.864,14.98 C14.9013335,14.838666 14.953333,14.7120006 15.02,14.6 C15.086667,14.4879994 15.1719995,14.3973337 15.276,14.328 C15.3800005,14.2586663 15.5093326,14.224 15.664,14.224 C15.8186674,14.224 15.9493328,14.2586663 16.056,14.328 C16.1626672,14.3973337 16.2506663,14.4879994 16.32,14.6 C16.3893337,14.7120006 16.4413332,14.838666 16.476,14.98 C16.5106668,15.121334 16.5373332,15.2639993 16.556,15.408 C16.5746668,15.5520007 16.5853333,15.6919993 16.588,15.828 C16.5906667,15.9640007 16.592,16.0799995 16.592,16.176 C16.592,16.3360008 16.5866667,16.5293322 16.576,16.756 C16.5653333,16.9826678 16.5320003,17.2013323 16.476,17.412 C16.4199997,17.6226677 16.329334,17.8026659 16.204,17.952 C16.078666,18.1013341 15.8986678,18.176 15.664,18.176 C15.4346655,18.176 15.2586673,18.1013341 15.136,17.952 C15.0133327,17.8026659 14.9240003,17.6226677 14.868,17.412 C14.8119997,17.2013323 14.7786667,16.9826678 14.768,16.756 C14.7573333,16.5293322 14.752,16.3360008 14.752,16.176 Z M18.064,16.176 C18.064,16.7360028 18.1186661,17.2039981 18.228,17.58 C18.3373339,17.9560019 18.4853324,18.2559989 18.672,18.48 C18.8586676,18.7040011 19.0759988,18.8639995 19.324,18.96 C19.5720012,19.0560005 19.8346653,19.104 20.112,19.104 C20.3946681,19.104 20.6599988,19.0560005 20.908,18.96 C21.1560012,18.8639995 21.3746657,18.7040011 21.564,18.48 C21.7533343,18.2559989 21.9026661,17.9560019 22.012,17.58 C22.1213339,17.2039981 22.176,16.7360028 22.176,16.176 C22.176,15.6319973 22.1213339,15.1746685 22.012,14.804 C21.9026661,14.4333315 21.7533343,14.1360011 21.564,13.912 C21.3746657,13.6879989 21.1560012,13.5280005 20.908,13.432 C20.6599988,13.3359995 20.3946681,13.288 20.112,13.288 C19.8346653,13.288 19.5720012,13.3359995 19.324,13.432 C19.0759988,13.5280005 18.8586676,13.6879989 18.672,13.912 C18.4853324,14.1360011 18.3373339,14.4333315 18.228,14.804 C18.1186661,15.1746685 18.064,15.6319973 18.064,16.176 Z M19.2,16.176 C19.2,16.0799995 19.2013333,15.9640007 19.204,15.828 C19.2066667,15.6919993 19.2159999,15.5520007 19.232,15.408 C19.2480001,15.2639993 19.2746665,15.121334 19.312,14.98 C19.3493335,14.838666 19.401333,14.7120006 19.468,14.6 C19.534667,14.4879994 19.6199995,14.3973337 19.724,14.328 C19.8280005,14.2586663 19.9573326,14.224 20.112,14.224 C20.2666674,14.224 20.3973328,14.2586663 20.504,14.328 C20.6106672,14.3973337 20.6986663,14.4879994 20.768,14.6 C20.8373337,14.7120006 20.8893332,14.838666 20.924,14.98 C20.9586668,15.121334 20.9853332,15.2639993 21.004,15.408 C21.0226668,15.5520007 21.0333333,15.6919993 21.036,15.828 C21.0386667,15.9640007 21.04,16.0799995 21.04,16.176 C21.04,16.3360008 21.0346667,16.5293322 21.024,16.756 C21.0133333,16.9826678 20.9800003,17.2013323 20.924,17.412 C20.8679997,17.6226677 20.777334,17.8026659 20.652,17.952 C20.526666,18.1013341 20.3466678,18.176 20.112,18.176 C19.8826655,18.176 19.7066673,18.1013341 19.584,17.952 C19.4613327,17.8026659 19.3720003,17.6226677 19.316,17.412 C19.2599997,17.2013323 19.2266667,16.9826678 19.216,16.756 C19.2053333,16.5293322 19.2,16.3360008 19.2,16.176 Z', @@ -68,8 +80,13 @@ export var ICON_PATHS = { search: 'M12 0 A12 12 0 0 0 0 12 A12 12 0 0 0 12 24 A12 12 0 0 0 18.5 22.25 L28 32 L32 28 L22.25 18.5 A12 12 0 0 0 24 12 A12 12 0 0 0 12 0 M12 4 A8 8 0 0 1 12 20 A8 8 0 0 1 12 4 ', star: 'M16 0 L21 11 L32 12 L23 19 L26 31 L16 25 L6 31 L9 19 L0 12 L11 11', statemap: 'M19.4375,6.97396734 L27,8.34417492 L27,25.5316749 L18.7837192,23.3765689 L11.875,25.3366259 L11.875,25.3366259 L11.875,11.0941749 L11.1875,11.0941749 L11.1875,25.5316749 L5,24.1566749 L5,7.65667492 L11.1875,9.03167492 L18.75,6.90135976 L18.75,22.0941749 L19.4375,22.0941749 L19.4375,6.97396734 Z', + string: { + path: 'M14.022,18 L11.533,18 C11.2543319,18 11.0247509,17.935084 10.84425,17.80525 C10.6637491,17.675416 10.538667,17.5091677 10.469,17.3065 L9.652,14.8935 L4.389,14.8935 L3.572,17.3065 C3.50866635,17.4838342 3.38516758,17.6437493 3.2015,17.78625 C3.01783241,17.9287507 2.79300133,18 2.527,18 L0.019,18 L5.377,4.1585 L8.664,4.1585 L14.022,18 Z M5.13,12.7085 L8.911,12.7085 L7.638,8.918 C7.55566626,8.67733213 7.45908389,8.3939183 7.34825,8.06775 C7.23741611,7.7415817 7.12816721,7.3885019 7.0205,7.0085 C6.91916616,7.39483527 6.8146672,7.75266502 6.707,8.082 C6.5993328,8.41133498 6.49800047,8.69633213 6.403,8.937 L5.13,12.7085 Z M21.945,18 C21.6663319,18 21.4557507,17.9620004 21.31325,17.886 C21.1707493,17.8099996 21.0520005,17.6516679 20.957,17.411 L20.748,16.8695 C20.5009988,17.078501 20.2635011,17.2621659 20.0355,17.4205 C19.8074989,17.5788341 19.5715846,17.7134161 19.32775,17.82425 C19.0839154,17.9350839 18.8242514,18.0174164 18.54875,18.07125 C18.2732486,18.1250836 17.9676683,18.152 17.632,18.152 C17.1823311,18.152 16.7738352,18.0934173 16.4065,17.97625 C16.0391648,17.8590827 15.7272513,17.6865011 15.47075,17.4585 C15.2142487,17.2304989 15.016334,16.947085 14.877,16.60825 C14.737666,16.269415 14.668,15.8783355 14.668,15.435 C14.668,15.0866649 14.7566658,14.7288352 14.934,14.3615 C15.1113342,13.9941648 15.4184978,13.6600848 15.8555,13.35925 C16.2925022,13.0584152 16.8814963,12.8066677 17.6225,12.604 C18.3635037,12.4013323 19.297661,12.2873335 20.425,12.262 L20.425,11.844 C20.425,11.2676638 20.3062512,10.8512513 20.06875,10.59475 C19.8312488,10.3382487 19.4940022,10.21 19.057,10.21 C18.7086649,10.21 18.4236678,10.2479996 18.202,10.324 C17.9803322,10.4000004 17.7824175,10.4854995 17.60825,10.5805 C17.4340825,10.6755005 17.2646675,10.7609996 17.1,10.837 C16.9353325,10.9130004 16.7390011,10.951 16.511,10.951 C16.3083323,10.951 16.1357507,10.9019172 15.99325,10.80375 C15.8507493,10.7055828 15.7383337,10.5836674 15.656,10.438 L15.124,9.5165 C15.7193363,8.99083071 16.3795797,8.59975128 17.10475,8.34325 C17.8299203,8.08674872 18.6073292,7.9585 19.437,7.9585 C20.0323363,7.9585 20.5690809,8.05508237 21.04725,8.24825 C21.5254191,8.44141763 21.9307483,8.71058161 22.26325,9.05575 C22.5957517,9.40091839 22.8506658,9.81099763 23.028,10.286 C23.2053342,10.7610024 23.294,11.2803305 23.294,11.844 L23.294,18 L21.945,18 Z M18.563,16.2045 C18.9430019,16.2045 19.2754986,16.1380007 19.5605,16.005 C19.8455014,15.8719993 20.1336652,15.6566682 20.425,15.359 L20.425,13.991 C19.8359971,14.0163335 19.3515019,14.0669996 18.9715,14.143 C18.5914981,14.2190004 18.2906678,14.3139994 18.069,14.428 C17.8473322,14.5420006 17.6937504,14.6718326 17.60825,14.8175 C17.5227496,14.9631674 17.48,15.1214991 17.48,15.2925 C17.48,15.6281683 17.5718324,15.8640827 17.7555,16.00025 C17.9391676,16.1364173 18.2083316,16.2045 18.563,16.2045 L18.563,16.2045 Z', + attrs: { viewBox: '0 0 24 24'} + }, table: 'M13.6373197,13.6373197 L18.3626803,13.6373197 L18.3626803,18.3626803 L13.6373197,18.3626803 L13.6373197,13.6373197 Z M18.9533504,18.9533504 L23.6787109,18.9533504 L23.6787109,23.6787109 L18.9533504,23.6787109 L18.9533504,18.9533504 Z M13.6373197,18.9533504 L18.3626803,18.9533504 L18.3626803,23.6787109 L13.6373197,23.6787109 L13.6373197,18.9533504 Z M8.32128906,18.9533504 L13.0466496,18.9533504 L13.0466496,23.6787109 L8.32128906,23.6787109 L8.32128906,18.9533504 Z M8.32128906,8.32128906 L13.0466496,8.32128906 L13.0466496,13.0466496 L8.32128906,13.0466496 L8.32128906,8.32128906 Z M8.32128906,13.6373197 L13.0466496,13.6373197 L13.0466496,18.3626803 L8.32128906,18.3626803 L8.32128906,13.6373197 Z M18.9533504,8.32128906 L23.6787109,8.32128906 L23.6787109,13.0466496 L18.9533504,13.0466496 L18.9533504,8.32128906 Z M18.9533504,13.6373197 L23.6787109,13.6373197 L23.6787109,18.3626803 L18.9533504,18.3626803 L18.9533504,13.6373197 Z M13.6373197,8.32128906 L18.3626803,8.32128906 L18.3626803,13.0466496 L13.6373197,13.0466496 L13.6373197,8.32128906 Z', trash: 'M4.31904507,29.7285487 C4.45843264,30.9830366 5.59537721,32 6.85726914,32 L20.5713023,32 C21.8337371,32 22.9701016,30.9833707 23.1095264,29.7285487 L25.1428571,11.4285714 L2.28571429,11.4285714 L4.31904507,29.7285487 L4.31904507,29.7285487 Z M6.85714286,4.57142857 L8.57142857,0 L18.8571429,0 L20.5714286,4.57142857 L25.1428571,4.57142857 C27.4285714,4.57142857 27.4285714,9.14285714 27.4285714,9.14285714 L13.7142857,9.14285714 L-1.0658141e-14,9.14285714 C-1.0658141e-14,9.14285714 -1.0658141e-14,4.57142857 2.28571429,4.57142857 L6.85714286,4.57142857 L6.85714286,4.57142857 Z M9.14285714,4.57142857 L18.2857143,4.57142857 L17.1428571,2.28571429 L10.2857143,2.28571429 L9.14285714,4.57142857 L9.14285714,4.57142857 Z', + unknown: 'M16.5,26.5 C22.0228475,26.5 26.5,22.0228475 26.5,16.5 C26.5,10.9771525 22.0228475,6.5 16.5,6.5 C10.9771525,6.5 6.5,10.9771525 6.5,16.5 C6.5,22.0228475 10.9771525,26.5 16.5,26.5 L16.5,26.5 Z M16.5,23.5 C12.6340068,23.5 9.5,20.3659932 9.5,16.5 C9.5,12.6340068 12.6340068,9.5 16.5,9.5 C20.3659932,9.5 23.5,12.6340068 23.5,16.5 C23.5,20.3659932 20.3659932,23.5 16.5,23.5 L16.5,23.5 Z', "illustration-icon-pie": { svg: "<path d='M29.8065455,22.2351515 L15.7837576,15.9495758 L15.7837576,31.2174545 C22.0004848,31.2029091 27.3444848,27.5258182 29.8065455,22.2351515' fill='#78B5EC'></path><g id='Fill-1-+-Fill-3'><path d='M29.8065455,22.2351515 C30.7316364,20.2482424 31.2630303,18.0402424 31.2630303,15.7032727 C31.2630303,11.8138182 29.8220606,8.26763636 27.4569697,5.54472727 L15.7837576,15.9495758 L29.8065455,22.2351515' fill='#3875AC'></path><path d='M27.4569697,5.54472727 C24.6118788,2.26909091 20.4266667,0.188121212 15.7478788,0.188121212 C7.17963636,0.188121212 0.232727273,7.1350303 0.232727273,15.7032727 C0.232727273,24.2724848 7.17963636,31.2184242 15.7478788,31.2184242 C15.7604848,31.2184242 15.7721212,31.2174545 15.7837576,31.2174545 L15.7837576,15.9495758 L27.4569697,5.54472727' fill='#4C9DE6'></path></g>" }, diff --git a/resources/frontend_client/app/lib/data_grid.js b/resources/frontend_client/app/lib/data_grid.js index a41a2697ff73346c5f440f622e09d07681ac3722..d01fab343835bb38fb339a09b7a80e65715a5e8d 100644 --- a/resources/frontend_client/app/lib/data_grid.js +++ b/resources/frontend_client/app/lib/data_grid.js @@ -2,7 +2,7 @@ import _ from "underscore"; -import SchemaMetadata from "metabase/lib/schema_metadata"; +import * as SchemaMetadata from "metabase/lib/schema_metadata"; function compareNumbers(a, b) { return a - b; @@ -50,13 +50,13 @@ var DataGrid = { } // sort the column values sensibly - if (SchemaMetadata.isNumericType(data.cols[pivotCol])) { + if (SchemaMetadata.isNumeric(data.cols[pivotCol])) { pivotColValues.sort(compareNumbers); } else { pivotColValues.sort(); } - if (SchemaMetadata.isNumericType(data.cols[normalCol])) { + if (SchemaMetadata.isNumeric(data.cols[normalCol])) { normalColValues.sort(compareNumbers); } else { normalColValues.sort(); diff --git a/resources/frontend_client/app/lib/formatting.js b/resources/frontend_client/app/lib/formatting.js index 5e0f146bc0758638f1b159eeb90f77d9f3f43fee..fad0581112c6678f322db302a7ae9dca3b5131da 100644 --- a/resources/frontend_client/app/lib/formatting.js +++ b/resources/frontend_client/app/lib/formatting.js @@ -1,6 +1,7 @@ "use strict"; import d3 from "d3"; +import inflection from "inflection"; var precisionNumberFormatter = d3.format(".2r"); var fixedNumberFormatter = d3.format(",.f"); @@ -22,3 +23,16 @@ export function formatScalar(scalar) { return String(scalar); } } + +export function singularize(...args) { + return inflection.singularize(...args); +} + +export function capitalize(...args) { + return inflection.capitalize(...args); +} + +// Removes trailing "id" from field names +export function stripId(name) { + return name && name.replace(/ id$/i, ""); +} diff --git a/resources/frontend_client/app/lib/query.js b/resources/frontend_client/app/lib/query.js index a242e5a6ccb4b00e5ed063a51347ee5ba08e18b2..64bad85c22645a8e325ba37bd07bd991107dc78e 100644 --- a/resources/frontend_client/app/lib/query.js +++ b/resources/frontend_client/app/lib/query.js @@ -340,11 +340,25 @@ var Query = { typeof field === "number" || (Array.isArray(field) && ( (field[0] === 'fk->' && typeof field[1] === "number" && typeof field[2] === "number") || + (field[0] === 'datetime_field' && Query.isValidField(field[1]) && field[2] === "as" && typeof field[3] === "string") || (field[0] === 'aggregation' && typeof field[1] === "number") )) ); }, + getFieldTarget: function(field, tableMetadata) { + let table, fieldId, fk; + if (Array.isArray(field) && field[0] === "fk->") { + fk = tableMetadata.fields_lookup[field[1]]; + table = fk.target.table; + fieldId = field[2]; + } else { + table = tableMetadata; + fieldId = field; + } + return { table, field: table.fields_lookup[fieldId] }; + }, + getFieldOptions: function(fields, includeJoins = false, filterFn = (fields) => fields, usedFields = {}) { var results = { count: 0, diff --git a/resources/frontend_client/app/lib/query_time.js b/resources/frontend_client/app/lib/query_time.js new file mode 100644 index 0000000000000000000000000000000000000000..912da8228cebb7bea71007c7bd781b605e8b457b --- /dev/null +++ b/resources/frontend_client/app/lib/query_time.js @@ -0,0 +1,183 @@ +"use strict"; + +import moment from "moment"; +import inflection from "inflection"; + +export function computeFilterTimeRange(filter) { + let expandedFilter; + if (filter[0] === "TIME_INTERVAL") { + expandedFilter = expandTimeIntervalFilter(filter); + } else { + expandedFilter = filter; + } + + let [operator, field, ...values] = expandedFilter; + let bucketing = parseFieldBucketing(field); + + let start, end; + if (operator === "=" && values[0]) { + let point = absolute(values[0]); + start = point.clone().startOf(bucketing); + end = point.clone().endOf(bucketing); + } else if (operator === ">" && values[0]) { + start = absolute(values[0]).endOf(bucketing); + end = max(); + } else if (operator === "<" && values[0]) { + start = min(); + end = absolute(values[0]).startOf(bucketing); + } else if (operator === "BETWEEN" && values[0] && values[1]) { + start = absolute(values[0]).startOf(bucketing); + end = absolute(values[1]).endOf(bucketing); + } + + return [start, end]; +} + +export function expandTimeIntervalFilter(filter) { + let [operator, field, n, unit] = filter; + + if (operator !== "TIME_INTERVAL") { + throw new Error("translateTimeInterval expects operator TIME_INTERVAL"); + } + + if (n === "current") { + n = 0; + } else if (n === "last") { + n = -1; + } else if (n === "next") { + n = 1; + } + + field = ["datetime_field", field, "as", unit]; + + if (n < -1) { + return ["BETWEEN", field, ["relative_datetime", n-1, unit], ["relative_datetime", -1, unit]]; + } else if (n > 1) { + return ["BETWEEN", field, ["relative_datetime", 1, unit], ["relative_datetime", n, unit]]; + } else if (n === 0) { + return ["=", field, ["relative_datetime", "current"]]; + } else { + return ["=", field, ["relative_datetime", n, unit]]; + } +} + +export function generateTimeFilterValuesDescriptions(filter) { + let [operator, field, ...values] = filter; + let bucketing = parseFieldBucketing(field); + + if (operator === "TIME_INTERVAL") { + let [n, unit] = values; + return generateTimeIntervalDescription(n, unit); + } else { + return values.map(value => generateTimeValueDescription(value, bucketing)); + } +} + +export function generateTimeIntervalDescription(n, unit) { + if (unit === "day") { + switch (n) { + case "current": + case 0: + return "Today"; + case "next": + case 1: + return "Tomorrow"; + case "last": + case -1: + return "Yesterday"; + } + } + unit = inflection.capitalize(unit); + if (typeof n === "string") { + if (n === "current") { + n = "this"; + } + return [inflection.capitalize(n) + " " + unit]; + } else { + if (n < 0) { + return ["Past " + (-n) + " " + inflection.inflect(unit, -n)]; + } else if (n > 0) { + return ["Next " + (n) + " " + inflection.inflect(unit, n)]; + } else { + return ["This " + unit]; + } + } +} + +export function generateTimeValueDescription(value, bucketing) { + if (typeof value === "string") { + return moment(value).format("MMMM D, YYYY"); + } else if (Array.isArray(value) && value[0] === "relative_datetime") { + let n = value[1]; + let unit = value[2]; + + if (n === "current") { + n = 0; + unit = bucketing; + } + + if (bucketing === unit) { + return generateTimeIntervalDescription(n, unit); + } else { + // FIXME: what to do if the bucketing and unit don't match? + if (n === 0) { + return "Now"; + } else { + return Math.abs(n) + " " + inflection.inflect(unit, Math.abs(n)) + (n < 0 ? " ago" : " from now"); + } + } + } else { + console.warn("Unknown datetime format", value); + return "[Unknown]"; + } +} + +export function formatBucketing(bucketing) { + let words = bucketing.split("-"); + words[0] = inflection.capitalize(words[0]); + return words.join(" "); +} + +export function absolute(date) { + if (typeof date === "string") { + return moment(date); + } else if (Array.isArray(date) && date[0] === "relative_datetime") { + return moment().add(date[1], date[2]); + } else { + console.warn("Unknown datetime format", date); + } +} + +export function parseFieldBucketing(field) { + if (Array.isArray(field)) { + if (field[0] === "datetime_field") { + return field[3]; + } if (field[0] === "fk->") { + return "day"; + } else { + console.warn("Unknown field format", field); + } + } + return "day"; +} + +export function parseFieldTarget(field) { + if (Array.isArray(field)) { + if (field[0] === "datetime_field") { + return field[1]; + } if (field[0] === "fk->") { + return field; + } else { + console.warn("Unknown field format", field); + } + } + return field; +} + +// 271821 BC and 275760 AD and should be far enough in the past/future +function max() { + return moment(new Date(864000000000000)); +} +function min() { + return moment(new Date(-864000000000000)); +} diff --git a/resources/frontend_client/app/lib/schema_metadata.js b/resources/frontend_client/app/lib/schema_metadata.js index 5b5577a20557bf8d7f41e40ea6e92f5dbae109fb..35bee131d3980e5babaa80f791ab3a0070a1b17c 100644 --- a/resources/frontend_client/app/lib/schema_metadata.js +++ b/resources/frontend_client/app/lib/schema_metadata.js @@ -2,44 +2,392 @@ import _ from "underscore"; +// create a standardized set of strings to return +export const TIME = 'TIME'; +export const NUMBER = 'NUMBER'; +export const STRING = 'STRING'; +export const BOOL = 'BOOL'; +export const LOCATION = 'LOCATION'; +export const UNKNOWN = 'UNKNOWN'; -var DateBaseTypes = ['DateTimeField', 'DateField']; -var DateSpecialTypes = ['timestamp_milliseconds', 'timestamp_seconds']; -var NumberBaseTypes = ['IntegerField', 'DecimalField', 'FloatField', 'BigIntegerField']; -var SummableBaseTypes = ['IntegerField', 'DecimalField', 'FloatField', 'BigIntegerField']; -var CategoryBaseTypes = ["BooleanField"]; -var CategorySpecialTypes = ["category", "zip_code", "city", "state", "country"]; +const DateBaseTypes = ['DateTimeField', 'DateField']; +const NumberBaseTypes = ['IntegerField', 'DecimalField', 'FloatField', 'BigIntegerField']; +const BooleanTypes = ["BooleanField"]; -function isInTypes(type, type_collection) { - if (_.indexOf(type_collection, type) >= 0) { - return true; +const SummableBaseTypes = ['IntegerField', 'DecimalField', 'FloatField', 'BigIntegerField']; +const CategoryBaseTypes = ["BooleanField"]; + +const DateSpecialTypes = ['timestamp_milliseconds', 'timestamp_seconds']; +const CategorySpecialTypes = ["category", "zip_code", "city", "state", "country"]; + +function isInTypes(type, typeCollection) { + return _.contains(typeCollection, type); +} + +export function isDate(field) { + return isInTypes(field.base_type, DateBaseTypes) || isInTypes(field.special_type, DateSpecialTypes); +} + +export function isNumeric(field) { + return isInTypes(field.base_type, NumberBaseTypes); +} + +export function isBoolean(field) { + return isInTypes(field.base_type, BooleanTypes); +} + +export function isSummable(field) { + return isInTypes(field.base_type, SummableBaseTypes); +} + +export function isCategory(field) { + return isInTypes(field.base_type, CategoryBaseTypes) || isInTypes(field.special_type, CategorySpecialTypes); +} + +export function isDimension(field) { + return isDate(field) || isCategory(field) || isInTypes(field.field_type, ['dimension']); +} + +// will return a string with possible values of 'date', 'number', 'bool', 'string' +// if the type cannot be parsed, then return undefined +export function getUmbrellaType(field) { + return parseSpecialType(field.special_type) || parseBaseType(field.base_type); +} + +export function parseBaseType(type) { + switch(type) { + case 'DateField': + case 'DateTimeField': + case 'TimeField': + return TIME; + case 'BigIntegerField': + case 'IntegerField': + case 'FloatField': + case 'DecimalField': + return NUMBER; + case 'CharField': + case 'TextField': + return STRING; + case 'BooleanField': + return BOOL; } - return false; +} +export function parseSpecialType(type) { + switch(type) { + case 'timestamp_milliseconds': + case 'timestamp_seconds': + return TIME; + case 'city': + case 'country': + case 'latitude': + case 'longitude': + case 'state': + case 'zipcode': + return LOCATION; + case 'name': + return STRING; + case 'number': + return NUMBER; + } } -var SchemaMetadata = { +function freeformArgument(field, table) { + return { + type: "text" + }; +} - isDateType: function(field) { - return isInTypes(field.base_type, DateBaseTypes) || isInTypes(field.special_type, DateSpecialTypes); - }, +function numberArgument(field, table) { + return { + type: "number" + }; +} - isNumericType: function(field) { - return isInTypes(field.base_type, NumberBaseTypes); - }, - isSummableType: function(field) { - return isInTypes(field.base_type, SummableBaseTypes); - }, +function comparableArgument(field, table) { + if (isNumeric(field)) { + return { + type: "number" + }; + } - isCategoryType: function(field) { - return isInTypes(field.base_type, CategoryBaseTypes) || isInTypes(field.special_type, CategorySpecialTypes); - }, + if (isDate(field)) { + return { + type: "date" + }; + } + + return { + type: "text" + }; +} - isDimension: function(field) { - return SchemaMetadata.isDateType(field) || SchemaMetadata.isCategoryType(field) || isInTypes(field.field_type, ['dimension']); + +function equivalentArgument(field, table) { + if (isBoolean(field)) { + return { + type: "select", + values: [ + { key: true, name: "True" }, + { key: false, name: "False" } + ] + }; + } + + if (isCategory(field)) { + if (field.id in table.field_values && table.field_values[field.id].length > 0) { + let validValues = table.field_values[field.id]; + validValues.sort(); + return { + type: "select", + values: validValues + .filter(value => value != null) + .map(value => ({ + key: value, + name: value + })) + }; + } + } + + if (isDate(field)) { + return { + type: "date" + }; } + + if (isNumeric(field)) { + return { + type: "number" + }; + } + + return { + type: "text" + }; +} + +function longitudeFieldSelectArgument(field, table) { + return { + type: "select", + values: table.fields + .filter(field => field.special_type === "longitude") + .map(field => ({ + key: field.id, + name: field.display_name + })) + }; +} + +const OPERATORS = { + "=": { + validArgumentsFilters: [equivalentArgument], + multi: true + }, + "!=": { + validArgumentsFilters: [equivalentArgument], + multi: true + }, + "IS_NULL": { + validArgumentsFilters: [] + }, + "NOT_NULL": { + validArgumentsFilters: [] + }, + "<": { + validArgumentsFilters: [comparableArgument] + }, + "<=": { + validArgumentsFilters: [comparableArgument] + }, + ">": { + validArgumentsFilters: [comparableArgument] + }, + ">=": { + validArgumentsFilters: [comparableArgument] + }, + "INSIDE": { + validArgumentsFilters: [longitudeFieldSelectArgument, numberArgument, numberArgument, numberArgument, numberArgument], + placeholders: ["Select longitude field", "Enter upper latitude", "Enter left longitude", "Enter lower latitude", "Enter right latitude"] + }, + "BETWEEN": { + validArgumentsFilters: [comparableArgument, comparableArgument] + }, + "STARTS_WITH": { + validArgumentsFilters: [freeformArgument] + }, + "ENDS_WITH": { + validArgumentsFilters: [freeformArgument] + }, + "CONTAINS": { + validArgumentsFilters: [freeformArgument] + } +}; + +// ordered list of operators and metadata per type +const OPERATORS_BY_TYPE_ORDERED = { + [NUMBER]: [ + { name: "=", verboseName: "Equal" }, + { name: "!=", verboseName: "Not equal" }, + { name: ">", verboseName: "Greater than" }, + { name: "<", verboseName: "Less than" }, + { name: "BETWEEN", verboseName: "Between" }, + { name: ">=", verboseName: "Greater than or equal to", advanced: true }, + { name: "<=", verboseName: "Less than or equal to", advanced: true }, + { name: "IS_NULL", verboseName: "Is empty", advanced: true }, + { name: "NOT_NULL",verboseName: "Not empty", advanced: true } + ], + [STRING]: [ + { name: "=", verboseName: "Is" }, + { name: "!=", verboseName: "Is not" }, + { name: "IS_NULL", verboseName: "Is empty", advanced: true }, + { name: "NOT_NULL",verboseName: "Not empty", advanced: true } + ], + [TIME]: [ + { name: "=", verboseName: "Is" }, + { name: "<", verboseName: "Before" }, + { name: ">", verboseName: "After" }, + { name: "BETWEEN", verboseName: "Between" } + ], + [LOCATION]: [ + { name: "=", verboseName: "Is" }, + { name: "!=", verboseName: "Is not" }, + { name: "INSIDE", verboseName: "Inside" } + ], + [BOOL]: [ + { name: "=", verboseName: "Is", multi: false, defaults: [true] }, + { name: "IS_NULL", verboseName: "Is empty" }, + { name: "NOT_NULL",verboseName: "Not empty" } + ], + [UNKNOWN]: [ + { name: "=", verboseName: "Is" }, + { name: "!=", verboseName: "Is not" }, + { name: "IS_NULL", verboseName: "Is empty", advanced: true }, + { name: "NOT_NULL",verboseName: "Not empty", advanced: true } + ] +}; + +const MORE_VERBOSE_NAMES = { + "equal": "is equal to", + "not equal": "is not equal to", + "before": "is before", + "after": "is afer", + "not empty": "is not empty", + "less than": "is less than", + "greater than": "is greater than", + "less than or equal to": "is less than or equal to", + "greater than or equal to": "is greater than or equal to", +} + +function getOperators(field, table) { + let type = getUmbrellaType(field) || UNKNOWN; + return OPERATORS_BY_TYPE_ORDERED[type].map(operatorForType => { + let operator = OPERATORS[operatorForType.name]; + let verboseNameLower = operatorForType.verboseName.toLowerCase(); + return { + ...operator, + ...operatorForType, + moreVerboseName: MORE_VERBOSE_NAMES[verboseNameLower] || verboseNameLower, + fields: operator.validArgumentsFilters.map(validArgumentsFilter => validArgumentsFilter(field, table)) + }; + }); +} + +// Breakouts and Aggregation options +function allFields(fields) { + return fields; +} + +function summableFields(fields) { + return _.filter(fields, isSummable); +} + +function dimensionFields(fields) { + return _.filter(fields, isDimension); +} + +var Aggregators = [{ + "name": "Raw data", + "short": "rows", + "description": "Just a table with the rows in the answer, no additional operations.", + "advanced": false, + "validFieldsFilters": [] +}, { + "name": "Count", + "short": "count", + "description": "Total number of rows in the answer.", + "advanced": false, + "validFieldsFilters": [] +}, { + "name": "Sum", + "short": "sum", + "description": "Sum of all the values of a column.", + "advanced": false, + "validFieldsFilters": [summableFields] +}, { + "name": "Average", + "short": "avg", + "description": "Average of all the values of a column", + "advanced": false, + "validFieldsFilters": [summableFields] +}, { + "name": "Number of distinct values", + "short": "distinct", + "description": "Number of unique values of a column among all the rows in the answer.", + "advanced": true, + "validFieldsFilters": [allFields] +}, { + "name": "Cumulative sum", + "short": "cum_sum", + "description": "Additive sum of all the values of a column.\ne.x. total revenue over time.", + "advanced": true, + "validFieldsFilters": [summableFields] +}, { + "name": "Standard deviation", + "short": "stddev", + "description": "Number which expresses how much the values of a colum vary among all rows in the answer.", + "advanced": true, + "validFieldsFilters": [summableFields] +}]; + +var BreakoutAggregator = { + "name": "Break out by dimension", + "short": "breakout", + "validFieldsFilters": [dimensionFields] }; +function populateFields(aggregator, fields) { + return { + 'name': aggregator.name, + 'short': aggregator.short, + 'description': aggregator.description || '', + 'advanced': aggregator.advanced || false, + 'fields': _.map(aggregator.validFieldsFilters, function(validFieldsFilterFn) { + return validFieldsFilterFn(fields); + }), + 'validFieldsFilters': aggregator.validFieldsFilters + }; +} + +function getAggregators(fields) { + return _.map(Aggregators, function(aggregator) { + return populateFields(aggregator, fields); + }); +} -export default SchemaMetadata; +function getBreakouts(fields) { + var result = populateFields(BreakoutAggregator, fields); + result.fields = result.fields[0]; + result.validFieldsFilter = result.validFieldsFilters[0]; + return result; +} + +export function addValidOperatorsToFields(table) { + for (let field of table.fields) { + field.valid_operators = getOperators(field, table); + } + table.aggregation_options = getAggregators(table.fields); + table.breakout_options = getBreakouts(table.fields); + return table; +} diff --git a/resources/frontend_client/app/lib/table.js b/resources/frontend_client/app/lib/table.js index 3149642293ec567913c2e81add394858eb8479a2..603309b8f0cd7a4f9225eff920f696aa9365990a 100644 --- a/resources/frontend_client/app/lib/table.js +++ b/resources/frontend_client/app/lib/table.js @@ -1,10 +1,5 @@ 'use strict'; -var Table = { - isQueryable: function(table) { - return table.visibility_type == null; - } -}; - - -export default Table; +export function isQueryable(table) { + return table.visibility_type == null; +} diff --git a/resources/frontend_client/app/query_builder/AccordianList.react.js b/resources/frontend_client/app/query_builder/AccordianList.react.js new file mode 100644 index 0000000000000000000000000000000000000000..b96a14acb5a6ab0d8c22cb27cd74870842a7578d --- /dev/null +++ b/resources/frontend_client/app/query_builder/AccordianList.react.js @@ -0,0 +1,130 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.react"; + +import cx from "classnames"; + +export default class AccordianList extends Component { + constructor(props) { + super(props); + this.state = { + openSection: undefined + }; + } + + toggleSection(sectionIndex) { + let openSection = this.getOpenSection(); + if (openSection === sectionIndex) { + this.setState({ openSection: null }); + } else { + this.setState({ openSection: sectionIndex }); + } + } + + getOpenSection() { + let { openSection } = this.state; + if (openSection === undefined) { + if (this.props.sectionIsSelected) { + for (let [index, section] of this.props.sections.entries()) { + if (this.props.sectionIsSelected(section, index)) { + openSection = index; + break; + } + } + } + if (openSection === undefined) { + openSection = 0; + } + } + return openSection; + } + + itemIsSelected(item) { + if (this.props.itemIsSelected) { + return this.props.itemIsSelected(item); + } else { + return false; + } + } + + onChange(item) { + if (this.props.onChange) { + this.props.onChange(item); + } + } + + renderItem(item) { + if (this.props.renderItem) { + return this.props.renderItem(item); + } else { + return ( + <div className="flex-full flex"> + <a className="flex-full flex align-center px2 py1 cursor-pointer" + onClick={this.onChange.bind(this, item)} + > + <h4 className="List-item-title ml2">{item.name}</h4> + </a> + </div> + ); + } + } + + renderSectionIcon(section, sectionIndex) { + if (this.props.renderSectionIcon) { + return this.props.renderSectionIcon(section, sectionIndex); + } else { + return null; + } + } + + render() { + let { sections } = this.props; + let openSection = this.getOpenSection(); + + return ( + <div className={this.props.className} style={{width: '300px'}}> + {sections.map((section, sectionIndex) => + <section key={sectionIndex}> + { section.name != null ? + <div className="p1 border-bottom"> + { sections.length > 1 ? + <div className="List-section-header px2 py1 cursor-pointer full flex align-center" onClick={() => this.toggleSection(sectionIndex)}> + { this.renderSectionIcon(section, sectionIndex) } + <h4>{section.name}</h4> + <span className="flex-align-right"> + <Icon name={openSection === sectionIndex ? "chevronup" : "chevrondown"} width={12} height={12} /> + </span> + </div> + : + <h4 className="px2 py1 text-default">{section.name}</h4> + } + </div> + : null } + { openSection === sectionIndex ? + <ul style={{maxHeight: 400}} className="p1 border-bottom scroll-y"> + {section.items.map((item, itemIndex) => { + return ( + <li key={itemIndex} className={cx("List-item flex", { 'List-item--selected': this.itemIsSelected(item) })}> + {this.renderItem(item, itemIndex)} + </li> + ) + })} + </ul> + : null } + </section> + )} + </div> + ); + } +} + +AccordianList.propTypes = { + sections: PropTypes.array.isRequired, + onChange: PropTypes.func, + sectionIsSelected: PropTypes.func, + itemIsSelected: PropTypes.func, + renderItem: PropTypes.func, + renderSectionIcon: PropTypes.func +}; diff --git a/resources/frontend_client/app/query_builder/AggregationWidget.react.js b/resources/frontend_client/app/query_builder/AggregationWidget.react.js index d012c1b10b4b98e2482d633f91e2d5638169ffe6..5e583eaa44393d492374adba8c1d73666ecbc214 100644 --- a/resources/frontend_client/app/query_builder/AggregationWidget.react.js +++ b/resources/frontend_client/app/query_builder/AggregationWidget.react.js @@ -1,26 +1,28 @@ -'use strict'; +"use strict"; -import _ from "underscore"; +import React, { Component, PropTypes } from "react"; import SelectionModule from './SelectionModule.react'; import FieldWidget from './FieldWidget.react'; -import Icon from "metabase/components/Icon.react"; import Query from "metabase/lib/query"; -export default React.createClass({ - displayName: 'AggregationWidget', - propTypes: { - aggregation: React.PropTypes.array.isRequired, - tableMetadata: React.PropTypes.object.isRequired, - updateAggregation: React.PropTypes.func.isRequired - }, +import _ from "underscore"; + +export default class AggregationWidget extends Component { + constructor(props) { + super(props); + + this.state = {}; + + _.bindAll(this, "setAggregation", "setAggregationTarget"); + } - componentWillMount: function() { + componentWillMount() { this.componentWillReceiveProps(this.props); - }, + } - componentWillReceiveProps: function(newProps) { + componentWillReceiveProps(newProps) { // build a list of aggregations that are valid, taking into account specifically if we have valid fields available var aggregationFieldOptions = []; var availableAggregations = []; @@ -43,9 +45,9 @@ export default React.createClass({ availableAggregations: availableAggregations, aggregationFieldOptions: aggregationFieldOptions }); - }, + } - setAggregation: function(aggregation) { + setAggregation(aggregation) { var queryAggregation = [aggregation]; // check to see if this aggregation type requires another choice @@ -59,16 +61,16 @@ export default React.createClass({ }); this.props.updateAggregation(queryAggregation); - }, + } - setAggregationTarget: function(target) { + setAggregationTarget(target) { var queryAggregation = this.props.aggregation; queryAggregation[1] = target; this.props.updateAggregation(queryAggregation); - }, + } - render: function() { + render() { if (this.props.aggregation.length === 0) { // we can't do anything without a valid aggregation return; @@ -86,6 +88,7 @@ export default React.createClass({ <div className="flex align-center"> <span className="text-bold">of</span> <FieldWidget + color="green" className="View-section-aggregation-target SelectionModule p1" tableName={this.props.tableMetadata.display_name} field={this.props.aggregation[1]} @@ -93,7 +96,6 @@ export default React.createClass({ setField={this.setAggregationTarget} isInitiallyOpen={aggregationTargetListOpen} /> - <Icon name="chevrondown" width="8px" height="8px" /> </div> ); } @@ -116,4 +118,10 @@ export default React.createClass({ </div> ); } -}); +} + +AggregationWidget.propTypes = { + aggregation: PropTypes.array.isRequired, + tableMetadata: PropTypes.object.isRequired, + updateAggregation: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/Calendar.react.js b/resources/frontend_client/app/query_builder/Calendar.react.js index 7b02615b639a17e2d93f894837cb41756fd6d2e2..0c9cf287a30ebea1535d2c7e9c03f801e057a040 100644 --- a/resources/frontend_client/app/query_builder/Calendar.react.js +++ b/resources/frontend_client/app/query_builder/Calendar.react.js @@ -1,115 +1,164 @@ "use strict"; -import Icon from "metabase/components/Icon.react"; +import React, { Component, PropTypes } from 'react'; +import cx from 'classnames'; +import moment from "moment"; -import cx from "classnames"; +import Icon from 'metabase/components/Icon.react'; -export default React.createClass({ - displayName: "Calendar", - propTypes: { - selected: React.PropTypes.object.isRequired - }, +const MODES = ['month', 'year', 'decade']; - getInitialState: function() { - return { - month: this.props.selected.clone() +export default class Calendar extends Component { + constructor(props) { + super(props); + + this.state = { + current: moment(props.initial || undefined), + currentMode: MODES[0] }; - }, + this.previous = this.previous.bind(this); + this.next = this.next.bind(this); + this.cycleMode = this.cycleMode.bind(this); + + this.onClickDay = this.onClickDay.bind(this); + } + + onClickDay(date, e) { + let { selected, selectedEnd } = this.props; + if (!selected || selectedEnd) { + this.props.onChange(date.format("YYYY-MM-DD"), null); + } else if (!selectedEnd) { + if (date.isAfter(selected)) { + this.props.onChange(selected.format("YYYY-MM-DD"), date.format("YYYY-MM-DD")); + } else { + this.props.onChange(date.format("YYYY-MM-DD"), selected.format("YYYY-MM-DD")); + } + } + } + + cycleMode() { + // let i = this.currentMode + // console.log('mode cycle y\'all') + // let i = ++i%this.state.modes.length; + // this.setState({ + // mode: this.modes[i] + // }) + } - previous: function() { - var month = this.state.month; + previous() { + let month = this.state.current; month.add(-1, "M"); this.setState({ month: month }); - }, + } - next: function() { - var month = this.state.month; + next() { + let month = this.state.current; month.add(1, "M"); this.setState({ month: month }); - }, - - render: function() { - return ( - <div className="Calendar"> - {this.renderMonthHeader()} - {this.renderDayNames()} - {this.renderWeeks()} - </div> - ); - }, + } - renderMonthHeader: function() { + renderMonthHeader() { return ( - <div className="Calendar-header flex align-center px2 mb1"> - <Icon name="chevronleft" width="10" height="12" onClick={this.previous} /> + <div className="Calendar-header flex align-center"> + <div className="bordered rounded p1 cursor-pointer transition-border border-hover px1" onClick={this.previous}> + <Icon name="chevronleft" width="10" height="12" /> + </div> <span className="flex-full" /> - <span className="h3 text-bold">{this.state.month.format("MMMM YYYY")}</span> + <h4 className="bordered border-hover cursor-pointer rounded p1" onClick={this.cycleMode}>{this.state.current.format("MMMM YYYY")}</h4> <span className="flex-full" /> - <Icon name="chevronright" width="10" height="12" onClick={this.next} /> + <div className="bordered border-hover rounded p1 transition-border cursor-pointer px1" onClick={this.next}> + <Icon name="chevronright" width="10" height="12" /> + </div> </div> ) - }, + } - renderDayNames: function() { - var names = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + renderDayNames() { + const names = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; return ( - <div className="Calendar-day-names Calendar-week border-bottom mb1"> - {names.map((name) => <span key={name} className="Calendar-day Calendar-day-name">{name}</span>)} + <div className="Calendar-day-names Calendar-week py1"> + {names.map((name) => <span key={name} className="Calendar-day-name text-centered">{name}</span>)} </div> ); - }, + } - renderWeeks: function() { + renderWeeks() { var weeks = [], done = false, - date = this.state.month.clone().startOf("month").add("w" -1).day("Sunday"), + date = moment(this.state.current).startOf("month").add("w" -1).day("Sunday"), monthIndex = date.month(), count = 0; while (!done) { - weeks.push(<Week - key={date.toString()} - date={date.clone()} - month={this.state.month} - onChange={this.props.onChange} - selected={this.props.selected} - />); + weeks.push( + <Week + key={date.toString()} + date={moment(date)} + month={this.state.current} + onClickDay={this.onClickDay} + selected={this.props.selected} + selectedEnd={this.props.selectedEnd} + /> + ); date.add(1, "w"); done = count++ > 2 && monthIndex !== date.month(); monthIndex = date.month(); } return ( - <div className="Calendar-weeks mt1">{weeks}</div> + <div className="Calendar-weeks">{weeks}</div> + ); + } + render() { + return ( + <div className={cx("Calendar", { "Calendar--range": this.props.selected && this.props.selectedEnd })}> + {this.renderMonthHeader()} + {this.renderDayNames()} + {this.renderWeeks()} + </div> ); } -}); +} -var Week = React.createClass({ - getDefaultProps: function() { - return { - onChange: () => {} - }; - }, +Calendar.propTypes = { + selected: PropTypes.object, + selectedEnd: PropTypes.object, + onChange: PropTypes.func.isRequired +}; - render: function() { - var days = [], - date = this.props.date, - month = this.props.month; +class Week extends Component { - for (var i = 0; i < 7; i++) { - var classes = cx({ + _dayIsSelected(day) { + return + } + + render() { + let days = []; + let { date, month, selected, selectedEnd } = this.props; + + for (let i = 0; i < 7; i++) { + let classes = cx({ + 'p1': true, + 'cursor-pointer': true, + 'text-centered': true, "Calendar-day": true, "Calendar-day--today": date.isSame(new Date(), "day"), "Calendar-day--this-month": date.month() === month.month(), - "Calendar-day--selected": date.isSame(this.props.selected) + "Calendar-day--selected": selected && date.isSame(selected, "day"), + "Calendar-day--selected-end": selectedEnd && date.isSame(selectedEnd, "day"), + "Calendar-day--week-start": i === 0, + "Calendar-day--week-end": i === 6, + "Calendar-day--in-range": !(date.isSame(selected, "day") || date.isSame(selectedEnd, "day")) && ( + date.isSame(selected, "day") || date.isSame(selectedEnd, "day") || + (selectedEnd && selectedEnd.isAfter(date, "day") && date.isAfter(selected, "day")) + ) }); days.push( - <span key={date.toString()} className={classes} onClick={this.props.onChange.bind(null, date)}> + <span key={date.toString()} className={classes} onClick={this.props.onClickDay.bind(null, date)}> {date.date()} </span> ); - date = date.clone(); + date = moment(date); date.add(1, "d"); } @@ -119,4 +168,10 @@ var Week = React.createClass({ </div> ); } -}); +} + +Week.propTypes = { + selected: PropTypes.object, + selectedEnd: PropTypes.object, + onClickDay: PropTypes.func.isRequired +} diff --git a/resources/frontend_client/app/query_builder/DataSelector.react.js b/resources/frontend_client/app/query_builder/DataSelector.react.js index f5f4e10b12164909f5599a6a8341a22b0a18cba0..f88ff9e53e4167ac0fd72c855c0f229ac975e843 100644 --- a/resources/frontend_client/app/query_builder/DataSelector.react.js +++ b/resources/frontend_client/app/query_builder/DataSelector.react.js @@ -1,38 +1,61 @@ "use strict"; +import React, { Component, PropTypes } from "react"; + import Icon from "metabase/components/Icon.react"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.react"; -import ColumnarSelector from "metabase/components/ColumnarSelector.react"; - -import Table from 'metabase/lib/table'; - -export default React.createClass({ - displayName: "DataSelector", - propTypes: { - query: React.PropTypes.object.isRequired, - databases: React.PropTypes.array.isRequired, - tables: React.PropTypes.array, - setDatabaseFn: React.PropTypes.func.isRequired, - setSourceTableFn: React.PropTypes.func, - isInitiallyOpen: React.PropTypes.bool, - name: React.PropTypes.string - }, - - getDefaultProps: function() { - return { - name: "Data", - isInitiallyOpen: false, - includeTables: false - }; - }, - - toggle: function() { +import AccordianList from "./AccordianList.react"; + +import { isQueryable } from 'metabase/lib/table'; + +import _ from "underscore"; + +export default class DataSelector extends Component { + constructor(props) { + super(props); + + _.bindAll(this, "onChange", "itemIsSelected", "sectionIsSelected"); + } + + onChange(item) { + if (item.database != null) { + this.props.setDatabaseFn(item.database.id); + } + if (item.table != null) { + this.props.setSourceTableFn(item.table.id); + } this.refs.popover.toggle(); - }, + } + + sectionIsSelected(section, sectionIndex) { + if (this.props.includeTables) { + return section.items[0].database.id === this.getDatabaseId(); + } else { + return true; + } + } + + itemIsSelected(item) { + if (this.props.includeTables) { + return item.table.id === this.getTableId(); + } else { + return item.database.id === this.getDatabaseId(); + } + } + + getDatabaseId() { + return this.props.query.database; + } - render: function() { - var database = this.props.databases && this.props.databases.filter((t) => t.id === this.props.query.database)[0] - var table = this.props.tables && this.props.tables.filter((t) => t.id === this.props.query.query.source_table)[0] + getTableId() { + return this.props.query.query && this.props.query.query.source_table; + } + + render() { + let dbId = this.getDatabaseId(); + let tableId = this.getTableId(); + var database = _.find(this.props.databases, (db) => db.id === dbId); + var table = _.find(database.tables, (table) => table.id === tableId); var content; if (this.props.includeTables) { @@ -56,55 +79,65 @@ export default React.createClass({ </span> ) - var columns = [ - { - title: "Databases", - selectedItem: database, - items: this.props.databases, - itemTitleFn: (db) => db.name, - itemSelectFn: (db) => { - this.props.setDatabaseFn(db.id) - if (!this.props.includeTables) { - this.toggle(); - } - } - } - ]; - + let sections; if (this.props.includeTables) { - if (database && this.props.tables) { - columns.push({ - title: database.name + " Tables", - selectedItem: table, - items: this.props.tables.filter(Table.isQueryable), - itemTitleFn: (table) => table.display_name, - itemSelectFn: (table) => { this.props.setSourceTableFn(table.id); this.toggle() } - }); - } else { - columns.push(null); - } + sections = this.props.databases.map(database => ({ + name: database.name, + items: database.tables.filter(isQueryable).map(table => ({ + name: table.display_name, + database: database, + table: table + })) + })) + } else { + sections = [{ + items: this.props.databases.map(database => ({ + name: database.name, + database: database + })) + }]; } - var tetherOptions = { - attachment: 'top left', - targetAttachment: 'bottom left', - targetOffset: '5px 0' - }; - - var name = this.props.name; - var classes = "GuiBuilder-section GuiBuilder-data flex align-center " + (this.props.className || ""); return ( - <div className={classes}> - <span className="GuiBuilder-section-label Query-label">{name}</span> - <PopoverWithTrigger ref="popover" - className="PopoverBody PopoverBody--withArrow" - isInitiallyOpen={this.props.isInitiallyOpen} - tetherOptions={tetherOptions} - triggerElement={triggerElement} - triggerClasses="flex align-center"> - <ColumnarSelector columns={columns}/> + <div className={"GuiBuilder-section GuiBuilder-data flex align-center " + this.props.className}> + <span className="GuiBuilder-section-label Query-label">{this.props.name}</span> + <PopoverWithTrigger + ref="popover" + className="PopoverBody PopoverBody--withArrow" + isInitiallyOpen={this.props.isInitiallyOpen} + triggerElement={triggerElement} + triggerClasses="flex align-center" + tetherOptions={{ + attachment: 'top left', + targetAttachment: 'bottom left', + targetOffset: '5px 0' + }} + > + <AccordianList + className="text-brand" + sections={sections} + onChange={this.onChange} + sectionIsSelected={this.sectionIsSelected} + itemIsSelected={this.itemIsSelected} + /> </PopoverWithTrigger> </div> ); - }, -}) + } +} + +DataSelector.propTypes = { + query: React.PropTypes.object.isRequired, + databases: React.PropTypes.array.isRequired, + setDatabaseFn: React.PropTypes.func.isRequired, + setSourceTableFn: React.PropTypes.func, + isInitiallyOpen: React.PropTypes.bool, + name: React.PropTypes.string +}; + +DataSelector.defaultProps = { + name: "Data", + className: "", + isInitiallyOpen: false, + includeTables: false +}; diff --git a/resources/frontend_client/app/query_builder/FieldList.react.js b/resources/frontend_client/app/query_builder/FieldList.react.js new file mode 100644 index 0000000000000000000000000000000000000000..d2999b19f1471d8f3108bbdae2aa6dea06270177 --- /dev/null +++ b/resources/frontend_client/app/query_builder/FieldList.react.js @@ -0,0 +1,150 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import AccordianList from "./AccordianList.react"; +import Icon from "metabase/components/Icon.react"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.react"; +import TimeGroupingPopover from "./TimeGroupingPopover.react"; + +import { isDate, getUmbrellaType, TIME, NUMBER, STRING, LOCATION } from 'metabase/lib/schema_metadata'; +import { parseFieldBucketing, parseFieldTarget } from "metabase/lib/query_time"; +import { stripId, singularize } from "metabase/lib/formatting"; + +import _ from "underscore"; + +const ICON_MAPPING = { + [TIME]: 'calendar', + [LOCATION]: 'location', + [STRING]: 'string', + [NUMBER]: 'int' +}; + +export default class FieldList extends Component { + constructor(props) { + super(props); + } + + componentWillMount() { + this.componentWillReceiveProps(this.props); + } + + componentWillReceiveProps(newProps) { + let { tableName, field, fieldOptions } = newProps; + + let mainSection = { + name: singularize(tableName), + items: fieldOptions.fields.map(field => ({ + field: field, + value: field.id + })) + }; + + let fkSections = fieldOptions.fks.map(fk => ({ + name: stripId(fk.field.display_name), + items: fk.fields.map(field => ({ + field: field, + value: ["fk->", fk.field.id, field.id] + })) + })); + + let sections = [mainSection].concat(fkSections); + let fieldTarget = parseFieldTarget(field); + + this.setState({ sections, fieldTarget }); + } + + sectionIsSelected(section, sectionIndex) { + let { sections, fieldTarget } = this.state; + let selectedSection = 0; + for (let i = 0; i < sections.length; i++) { + if (_.some(sections[i].items, (item) => _.isEqual(fieldTarget, item.value))) { + selectedSection = i; + break; + } + } + return selectedSection === sectionIndex; + } + + itemIsSelected(item) { + return _.isEqual(this.state.fieldTarget, item.value); + } + + renderItem(item) { + let { field } = this.props; + return ( + <div className="flex-full flex"> + <a className="flex-full flex align-center px2 py1 cursor-pointer" + onClick={this.props.onFieldChange.bind(null, item.value)} + > + { this.renderTypeIcon(item.field) } + <h4 className="List-item-title ml2">{item.field.display_name}</h4> + </a> + { this.props.enableTimeGrouping && isDate(item.field) ? + <PopoverWithTrigger + className={"PopoverBody " + this.props.className} + triggerElement={this.renderTimeGroupingTrigger(field)} + tetherOptions={{ + attachment: 'top left', + targetAttachment: 'top right', + targetOffset: '0 0' + // constraints: [{ to: 'window', attachment: 'together', pin: ['top', 'bottom']}] + }} + > + <TimeGroupingPopover + field={field} + value={item.value} + onFieldChange={this.props.onFieldChange} + /> + </PopoverWithTrigger> + : null } + </div> + ); + } + + renderTypeIcon(field) { + let type = getUmbrellaType(field); + let name = ICON_MAPPING[type] || 'unknown'; + return <Icon name={name} width={18} height={18} />; + } + + renderTimeGroupingTrigger(field) { + return ( + <div className="FieldList-grouping-trigger flex align-center p1 cursor-pointer"> + <h4 className="mr1">by {parseFieldBucketing(field).split("-").join(" ")}</h4> + <Icon name="chevronright" width={16} height={16} /> + </div> + ) + } + + renderSectionIcon(section, sectionIndex) { + if (sectionIndex > 0) { + return ( + <span className="mr2"> + <Icon name="connections" width={18} height={18} /> + </span> + ); + } + } + + render() { + return ( + <AccordianList + className={this.props.className} + sections={this.state.sections} + sectionIsSelected={this.sectionIsSelected.bind(this)} + itemIsSelected={this.itemIsSelected.bind(this)} + renderItem={this.renderItem.bind(this)} + renderSectionIcon={this.renderSectionIcon.bind(this)} + /> + ) + } +} + +FieldList.propTypes = { + field: PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.array]), + fieldOptions: PropTypes.object.isRequired, + tableName: PropTypes.string, + onFieldChange: PropTypes.func.isRequired, + enableTimeGrouping: PropTypes.bool +}; diff --git a/resources/frontend_client/app/query_builder/FieldName.react.js b/resources/frontend_client/app/query_builder/FieldName.react.js index 971039805d0a596f40d24d8b149639ecd9c52864..0a8e7d15e066c8a9d2028340aa5df0708959b7d8 100644 --- a/resources/frontend_client/app/query_builder/FieldName.react.js +++ b/resources/frontend_client/app/query_builder/FieldName.react.js @@ -5,6 +5,10 @@ import _ from "underscore"; import Icon from "metabase/components/Icon.react"; import Query from "metabase/lib/query"; +import { parseFieldTarget, parseFieldBucketing, formatBucketing } from "metabase/lib/query_time"; +import { isDate } from "metabase/lib/schema_metadata"; + +import { stripId } from "metabase/lib/formatting"; import cx from "classnames"; @@ -24,29 +28,37 @@ export default React.createClass({ }, render: function() { - var targetTitle, fkTitle, fkIcon; - var field = this.props.field; + let targetTitle, fkTitle, fkIcon, bucketingTitle; + let { field, fieldOptions } = this.props; + + let bucketing = parseFieldBucketing(field); + field = parseFieldTarget(field); + let fieldDef; if (Array.isArray(field) && field[0] === 'fk->') { - var fkDef = _.find(this.props.fieldOptions.fks, (fk) => _.isEqual(fk.field.id, field[1])); + let fkDef = _.find(fieldOptions.fks, (fk) => _.isEqual(fk.field.id, field[1])); if (fkDef) { - fkTitle = (<span>{fkDef.field.display_name}</span>); - var targetDef = _.find(fkDef.fields, (f) => _.isEqual(f.id, field[2])); - if (targetDef) { - targetTitle = (<span>{targetDef.display_name}</span>); + fkTitle = (<span>{stripId(fkDef.field.display_name)}</span>); + fieldDef = _.find(fkDef.fields, (f) => _.isEqual(f.id, field[2])); + if (fieldDef) { fkIcon = (<span className="px1"><Icon name="connections" width="10" height="10" /></span>); } } } else { - var fieldDef = _.find(this.props.fieldOptions.fields, (f) => _.isEqual(f.id, field)); - if (fieldDef) { - targetTitle = (<span>{fieldDef.display_name}</span>); - } + fieldDef = _.find(fieldOptions.fields, (f) => _.isEqual(f.id, field)); + } + + if (fieldDef) { + targetTitle = (<span>{fieldDef.display_name}</span>); + } + + if (fieldDef && isDate(fieldDef)) { + bucketingTitle = ": " + formatBucketing(bucketing); } var titleElement; if (fkTitle || targetTitle) { - titleElement = <span className="QueryOption">{fkTitle}{fkIcon}{targetTitle}</span>; + titleElement = <span className="QueryOption">{fkTitle}{fkIcon}{targetTitle}{bucketingTitle}</span>; } else { titleElement = <span className="QueryOption">field</span>; } diff --git a/resources/frontend_client/app/query_builder/FieldSelector.react.js b/resources/frontend_client/app/query_builder/FieldSelector.react.js deleted file mode 100644 index 29c6e1e9b7b5339e41b567f529fcd5f34bb658bc..0000000000000000000000000000000000000000 --- a/resources/frontend_client/app/query_builder/FieldSelector.react.js +++ /dev/null @@ -1,118 +0,0 @@ -"use strict"; - -import _ from "underscore"; - -import ColumnarSelector from "metabase/components/ColumnarSelector.react"; - -import Query from "metabase/lib/query"; - -export default React.createClass({ - displayName: "FieldSelector", - propTypes: { - field: React.PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.array]), - fieldOptions: React.PropTypes.object.isRequired, - tableName: React.PropTypes.string, - setField: React.PropTypes.func.isRequired - }, - - getInitialState: function() { - return { - // must use "undefined" not "null" since null signifies the table itself is "selected" in the first column - partialField: undefined - }; - }, - - setField: function(field) { - if (Query.isValidField(field)) { - this.setState({ partialField: undefined }); - this.props.setField(field); - } else { - this.setState({ partialField: field }); - } - }, - - render: function() { - var field = this.state.partialField !== undefined ? this.state.partialField : this.props.field; - - var sourceTable = { - title: this.props.tableName || null, - field: null - }; - - if (!this.props.fieldOptions) { - return <div>blah</div>; - } - - var connectionTables = this.props.fieldOptions.fks - .map((fk) => { - return { - title: fk.field.display_name, - subtitle: this.props.tableName || null, - field: ["fk->", fk.field.id, null], - fieldId: fk.field.id - }; - }); - - var tableSections = [ - { - title: "Source", - items: [sourceTable] - } - ]; - if (connectionTables.length > 0) { - tableSections.push({ - title: "Connections", - items: connectionTables - }); - } - - var tableColumn = { - sections: tableSections, - selectedItem: null, - itemTitleFn: (table) => { - var subtitleElement = table.subtitle ? <div className="text-grey-3 mb1">{table.subtitle}</div> : null; - return ( - <div> - {subtitleElement} - <div>{table.title}</div> - </div> - ); - }, - itemSelectFn: (table) => { - this.setField(table.field); - } - } - - var fieldColumn = { - items: null, - selectedItem: null, - itemTitleFn: (field) => field.display_name, - itemSelectFn: null - }; - - if (field == undefined || typeof field === "number" || field[0] === "aggregation") { - tableColumn.selectedItem = sourceTable; - fieldColumn.items = this.props.fieldOptions.fields; - fieldColumn.selectedItem = _.find(this.props.fieldOptions.fields, (f) => _.isEqual(f.id, field)); - fieldColumn.itemSelectFn = (f) => { - this.setField(f.id); - } - } else { - tableColumn.selectedItem = _.find(connectionTables, (t) => _.isEqual(t.fieldId, field[1])); - fieldColumn.items = _.find(this.props.fieldOptions.fks, (fk) => _.isEqual(fk.field.id, tableColumn.selectedItem.fieldId)).fields; - fieldColumn.selectedItem = _.find(fieldColumn.items, (f) => _.isEqual(f.id, field[2])); - fieldColumn.itemSelectFn = (f) => { - this.setField(["fk->", field[1], f.id]); - } - } - - var columns = [ - tableColumn, - fieldColumn - ]; - - return ( - <ColumnarSelector columns={columns}/> - ); - } -}); diff --git a/resources/frontend_client/app/query_builder/FieldWidget.react.js b/resources/frontend_client/app/query_builder/FieldWidget.react.js index 5b5b5847e1a7d5c3dd0596270c78f0d4dccbeaed..4d42829b5cb4ba3d89301ccd20d16d0a8f31be54 100644 --- a/resources/frontend_client/app/query_builder/FieldWidget.react.js +++ b/resources/frontend_client/app/query_builder/FieldWidget.react.js @@ -1,39 +1,38 @@ "use strict"; -import FieldSelector from "./FieldSelector.react"; +import React, { Component, PropTypes } from "react"; + +import FieldList from "./FieldList.react"; import FieldName from "./FieldName.react"; import Popover from "metabase/components/Popover.react"; import Query from "metabase/lib/query"; -export default React.createClass({ - displayName: "FieldWidget", - propTypes: { - field: React.PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.array]), - fieldOptions: React.PropTypes.object.isRequired, - setField: React.PropTypes.func.isRequired, - removeField: React.PropTypes.func, - isInitiallyOpen: React.PropTypes.bool - }, +import _ from "underscore"; + +export default class FieldWidget extends Component { + constructor(props) { + super(props); - getInitialState: function() { - return { - isOpen: this.props.isInitiallyOpen || false + this.state = { + isOpen: props.isInitiallyOpen || false }; - }, - setField:function(value) { + _.bindAll(this, "toggle", "setField"); + } + + setField(value) { this.props.setField(value); if (Query.isValidField(value)) { this.toggle(); } - }, + } - toggle: function() { + toggle() { this.setState({ isOpen: !this.state.isOpen }); - }, + } - renderPopover: function() { + renderPopover() { if (this.state.isOpen) { var tetherOptions = { attachment: 'top center', @@ -47,18 +46,20 @@ export default React.createClass({ tetherOptions={tetherOptions} onClose={this.toggle} > - <FieldSelector + <FieldList + className={"text-" + this.props.color} tableName={this.props.tableName} field={this.props.field} fieldOptions={this.props.fieldOptions} - setField={this.setField} + onFieldChange={this.setField} + enableTimeGrouping={true} /> </Popover> ); } - }, + } - render: function() { + render() { return ( <div className="flex align-center"> <FieldName @@ -72,4 +73,16 @@ export default React.createClass({ </div> ); } -}); +} + +FieldWidget.propTypes = { + field: PropTypes.oneOfType([PropTypes.number, PropTypes.array]), + fieldOptions: PropTypes.object.isRequired, + setField: PropTypes.func.isRequired, + removeField: PropTypes.func, + isInitiallyOpen: PropTypes.bool +}; + +FieldWidget.defaultProps = { + color: "brand" +}; diff --git a/resources/frontend_client/app/query_builder/FilterWidget.react.js b/resources/frontend_client/app/query_builder/FilterWidget.react.js deleted file mode 100644 index 8ce4bd20db22975de11168d29418352f5987383b..0000000000000000000000000000000000000000 --- a/resources/frontend_client/app/query_builder/FilterWidget.react.js +++ /dev/null @@ -1,446 +0,0 @@ -'use strict'; - -import _ from "underscore"; - -import Calendar from './Calendar.react'; -import Icon from "metabase/components/Icon.react"; -import FieldName from './FieldName.react'; -import FieldSelector from './FieldSelector.react'; -import Popover from "metabase/components/Popover.react"; -import ColumnarSelector from "metabase/components/ColumnarSelector.react"; - -import Query from "metabase/lib/query"; -import moment from 'moment'; - -import cx from "classnames"; - -export default React.createClass({ - displayName: 'FilterWidget', - propTypes: { - filter: React.PropTypes.array.isRequired, - tableMetadata: React.PropTypes.object.isRequired, - index: React.PropTypes.number.isRequired, - updateFilter: React.PropTypes.func.isRequired, - removeFilter: React.PropTypes.func.isRequired - }, - - getInitialState: function() { - return { - currentPane: this.props.filter[0] == undefined ? 0 : -1 - }; - }, - - componentWillMount: function() { - this.componentWillReceiveProps(this.props); - }, - - componentWillReceiveProps: function(newProps) { - var operator = newProps.filter[0], // name of the operator - field = newProps.filter[1], // id of the field - values = null; // filtering value - - if (newProps.filter.length > 2) { - values = []; - - for (var i=2; i < newProps.filter.length; i++) { - var valuesIdx = i - 2; - - values[valuesIdx] = null; - if (newProps.filter[i] !== null) { - // always cast the underlying value to a string, otherwise we get strange behavior on dealing with input - values[valuesIdx] = newProps.filter[i].toString(); - } - } - } - - // if we know what field we are filtering by we can extract the fieldDef to help us with filtering choices - var fieldDef - if (Array.isArray(field)) { - var fkDef = newProps.tableMetadata.fields_lookup[field[1]]; - if (fkDef) { - fieldDef = fkDef.target.table.fields_lookup[field[2]]; - } - } else { - fieldDef = newProps.tableMetadata.fields_lookup[field]; - } - - // once we know our field we can pull out the list of possible operators to filter on - // also, if we know the operator then we can pull out the possible values for the field (if available) - // TODO: why is fieldValues a function of the operator? - var operatorList = [], - fieldValues; - if (fieldDef) { - for(var idx in fieldDef.operators_lookup) { - var operatorDef = fieldDef.operators_lookup[idx]; - operatorList.push(operatorDef); - - if(operatorDef.name === operator) { - // this is structured strangely - fieldValues = operatorDef.fields[0]; - } - } - } - - // this converts our fieldValues into things that are safe for us to work with through HTML - // it also filters out values like NULL which we don't want in our value options - if (fieldValues && fieldValues.values) { - var safeValues = []; - for (var idx2 in fieldValues.values) { - var fieldValue = fieldValues.values[idx2]; - - var safeValue = {}; - for(var key in fieldValue) { - // NOTE: we specifically prevent any keys which are NULL values because those should be expressed using IS_NULL or NOT_NULL operators - if (fieldValue[key] !== undefined && fieldValue[key] !== null) { - safeValue[key] = fieldValue[key].toString(); - } - } - - if (Object.getOwnPropertyNames(safeValue).length > 0) { - safeValues.push(safeValue); - } - } - - fieldValues.values = safeValues; - } - - var fieldOptions = Query.getFieldOptions(newProps.tableMetadata.fields, true); - - this.setState({ - field: field, - operator: operator, - operatorList: operatorList, - values: values, - fieldValues: fieldValues, - fieldDef: fieldDef, - fieldOptions: fieldOptions - }); - }, - - isVisible: function() { - return this.state.currentPane >= 0 && this.state.currentPane < this.props.filter.length - }, - - selectPane: function(index) { - this.setState({ currentPane: index }); - }, - - hasField: function() { - return Query.isValidField(this.state.field); - }, - - hasOperator: function() { - return (typeof this.state.operator === "string"); - }, - - setField: function(value) { - // whenever the field is set we completely clear the filter and reset it, this is because some operators and values don't - // make sense once you've changed the field, so starting fresh is the most sensible thing to do - if (!_.isEqual(this.state.field, value)) { - var filter = [null, value, null]; - this.props.updateFilter(this.props.index, filter); - } - if (Query.isValidField(value)) { - this.selectPane(1); - } - }, - - setOperator: function(value) { - // different operators will lead to different filter scenarios, so handle that here - var operatorInfo = this.state.fieldDef.operators_lookup[value]; - var filter = this.props.filter; - - if (operatorInfo.validArgumentsFilters.length !== this.props.filter.length) { - // looks like our new filter operator expects a different length filter from our current - filter = []; - for(var i=0; i < operatorInfo.validArgumentsFilters.length + 2; i++) { - filter[i] = null; - } - - // anything after 2 positions is going to be variable - for (var j=0; j < filter.length; j++) { - if (this.props.filter.length >= j+1) { - filter[j] = this.props.filter[j]; - } - } - - // make sure we set the updated operator - filter[0] = value; - - } else { - filter[0] = value; - } - - this.props.updateFilter(this.props.index, filter); - - this.selectPane(2); - }, - - setValue: function(index, value) { - var filter = this.props.filter; - - if (value && value.length > 0) { - // value casting. we need the value in the filter to be of the proper type - if (this.state.fieldDef.special_type === "timestamp_milliseconds" || - this.state.fieldDef.special_type === "timestamp_seconds") { - } else if (this.state.fieldDef.base_type === "IntegerField" || - this.state.fieldDef.base_type === "SmallIntegerField" || - this.state.fieldDef.base_type === "BigIntegerField") { - value = parseInt(value); - } else if (this.state.fieldDef.base_type === "BooleanField") { - value = (value.toLowerCase() === "true") ? true : false; - } else if (this.state.fieldDef.base_type === "FloatField" || - this.state.fieldDef.base_type === "DecimalField") { - value = parseFloat(value); - } - - // TODO: we may need to do some date formatting work on DateTimeField and DateField - } else { - value = null; - } - - if (value !== undefined) { - filter[index + 2] = value; - this.props.updateFilter(this.props.index, filter); - } - - var nextPane = index + 2 + 1; - if (nextPane < filter.length) { - this.selectPane(nextPane); - } else { - this.selectPane(-1); - } - }, - - setDateValue: function (index, date) { - this.setValue(index, date.format('YYYY-MM-DD')); - }, - - setTextValue: function(index) { - var value = this.refs.textFilterValue.getDOMNode().value; - // we always know the index will be 2 for the value of a filter - this.setValue(index, value); - }, - - removeFilterFn: function() { - this.props.removeFilter(this.props.index); - }, - - renderField: function() { - var classes = cx({ - 'Filter-section': true, - 'Filter-section-field': true, - 'px1': true, - 'pt1': true - }); - - return ( - <FieldName - className={classes} - field={this.state.field} - fieldOptions={this.state.fieldOptions} - onClick={this.selectPane.bind(null, 0)} - /> - ); - }, - - renderOperator: function() { - var operator; - // if we don't know our field yet then don't render anything - if (this.hasField()) { - operator = _.find(this.state.operatorList, (o) => o.name === this.state.operator); - } - var operatorName = operator ? operator.verbose_name : "operator"; - - var classes = cx({ - "SelectionModule": true, - "Filter-section": true, - "Filter-section-operator": true, - "selected": !!operator - }) - return ( - <div className={classes} onClick={this.selectPane.bind(null, 1)}> - <a className="QueryOption p1 flex align-center">{operatorName}</a> - </div> - ); - }, - - renderValues: function() { - // if we don't know our field AND operator yet then don't render anything - if (!this.hasField() || this.state.operator === null) { - return false; - } - - // the first 2 positions of the filter are always for fieldId + fieldOperator - return this.props.filter.slice(2).map((filterValue, valueIndex) => { - var filterIndex = valueIndex + 2; - var value = this.state.values[valueIndex]; - if (this.state.fieldValues) { - var filterSectionClasses = cx({ - "Filter-section": true, - "Filter-section-value": true, - "selected": filterValue != null - }); - var queryOptionClasses = {}; - queryOptionClasses["QueryOption"] = true - queryOptionClasses["QueryOption--" + this.state.fieldValues.type] = true; - var valueString; - if (this.state.fieldValues.type === "date") { - valueString = value ? moment(value).format("MMMM D, YYYY") : "date"; - } else { - valueString = value != null ? value.toString() : "value"; - } - return ( - <div key={valueIndex} className={filterSectionClasses} onClick={this.selectPane.bind(null, filterIndex)}> - <span className={cx(queryOptionClasses)}>{valueString}</span> - </div> - ); - } - }); - }, - - renderFieldPane: function() { - return ( - <FieldSelector - field={this.state.field} - fieldOptions={this.state.fieldOptions} - tableName={this.props.tableMetadata.display_name} - setField={this.setField} - /> - ); - }, - - renderOperatorPane: function() { - var column = { - selectedItem: _.find(this.state.operatorList, (o) => o.name === this.state.operator), - items: this.state.operatorList, - itemTitleFn: (o) => o.verbose_name, - itemSelectFn: (o, index) => this.setOperator(o.name, index) - }; - return ( - <ColumnarSelector columns={[column]} /> - ); - }, - - renderValuePane: function(valueIndex) { - if (this.state.fieldValues && this.state.values && valueIndex <= this.state.values.length) { - var value = this.state.values[valueIndex]; - if (this.state.fieldValues.values) { - var column = { - selectedItem: _.find(this.state.fieldValues.values, (v) => v.key === value), - items: this.state.fieldValues.values, - itemTitleFn: (v) => v.name, - itemSelectFn: (v, index) => this.setValue(valueIndex, v.key) - }; - return ( - <ColumnarSelector columns={[column]} /> - ); - } else if (this.state.fieldValues.type === "date") { - var date = value ? moment(value) : moment(); - return ( - <div className="flex layout-centered m2"> - <Calendar - selected={date} - onChange={this.setDateValue.bind(null, valueIndex)} - /> - </div> - ); - } else { - return ( - <div className="Filter-section Filter-section-value flex p2"> - <input - className="QueryOption input mx1 flex-full" - type="text" - defaultValue={value} - ref="textFilterValue" - placeholder="What value?" - autoFocus={true} - /> - <button className="Button mx1 text-default text-normal" onClick={() => this.setTextValue(valueIndex, this.refs.textFilterValue.value)}> - Add - </button> - </div> - ); - } - } - return <div><div>{value}</div><pre>{JSON.stringify(this.state.fieldValues)}</pre></div>; - }, - - renderPopover: function() { - if (this.isVisible()) { - var pane; - if (this.state.currentPane === 0) { - pane = this.renderFieldPane(); - } else if (this.state.currentPane === 1) { - pane = this.renderOperatorPane(); - } else { - pane = this.renderValuePane(this.state.currentPane - 2); - } - - var tabs = [ - { name: "Field", enabled: true }, - { name: "Operator", enabled: this.hasField() } - ]; - - var numValues = this.props.filter.length - 2; - for (var i = 0; i < numValues; i++) { - tabs.push({ name: "Value", enabled: this.hasField() && this.state.operator != null }); - } - - var tetherOptions = { - attachment: 'top left', - targetAttachment: 'bottom left', - targetOffset: '10px 0' - }; - - return ( - <Popover - ref="popover" - className="PopoverBody PopoverBody--withArrow FilterPopover" - isInitiallyOpen={this.state.field === null} - tetherOptions={tetherOptions} - onClose={this.selectPane.bind(null, -1)} - > - <ul className="PopoverHeader"> - {tabs.map((t, index) => { - var classes = cx({ - "PopoverHeader-item": true, - "PopoverHeader-item--withArrow": index < tabs.length, - "cursor-pointer": t.enabled, - "selected": this.state.currentPane === index, - "disabled": !t.enabled - }); - return <li key={index} className={classes} onClick={this.selectPane.bind(null, index)}>{t.name}</li> - })} - </ul> - <div>{pane}</div> - </Popover> - ); - } - }, - - render: function() { - var classes = cx({ - "Query-filter": true, - "px1": true, - "selected": this.isVisible() - }); - return ( - <div className={classes}> - <div> - <div> - {this.renderField()} - </div> - <div className="flex align-center"> - {this.renderOperator()} - {this.renderValues()} - </div> - {this.renderPopover()} - </div> - <a className="text-grey-2 no-decoration px1 flex align-center" href="#" onClick={this.removeFilterFn}> - <Icon name='close' width="14px" height="14px" /> - </a> - </div> - ); - } -}); diff --git a/resources/frontend_client/app/query_builder/GuiQueryEditor.react.js b/resources/frontend_client/app/query_builder/GuiQueryEditor.react.js index 6afe6fd9d60c213cf96341fcf9955b6dc96cf9e6..e4579956948c3c51e8fa386dc0d3a48d61739663 100644 --- a/resources/frontend_client/app/query_builder/GuiQueryEditor.react.js +++ b/resources/frontend_client/app/query_builder/GuiQueryEditor.react.js @@ -1,9 +1,12 @@ -'use strict'; +"use strict"; + +import React, { Component, PropTypes } from "react"; import AggregationWidget from './AggregationWidget.react'; import DataSelector from './DataSelector.react'; import FieldWidget from './FieldWidget.react'; -import FilterWidget from './FilterWidget.react'; +import FilterWidget from './filters/FilterWidget.react'; +import FilterPopover from './filters/FilterPopover.react'; import Icon from "metabase/components/Icon.react"; import IconBorder from 'metabase/components/IconBorder.react'; import SortWidget from './SortWidget.react'; @@ -13,87 +16,90 @@ import MetabaseAnalytics from 'metabase/lib/analytics'; import Query from "metabase/lib/query"; import cx from "classnames"; +import _ from "underscore"; + +export default class GuiQueryEditor extends Component { + constructor(props) { + super(props); -export default React.createClass({ - displayName: 'GuiQueryEditor', - propTypes: { - databases: React.PropTypes.array.isRequired, - query: React.PropTypes.object.isRequired, - tableMetadata: React.PropTypes.object, // can't be required, sometimes null - isShowingDataReference: React.PropTypes.bool.isRequired, - setQueryFn: React.PropTypes.func.isRequired, - setDatabaseFn: React.PropTypes.func.isRequired, - setSourceTableFn: React.PropTypes.func.isRequired, - toggleExpandCollapseFn: React.PropTypes.func.isRequired - }, - - getInitialState: function() { - return { + this.state = { expanded: true }; - }, - setQuery: function(dataset_query) { + _.bindAll( + this, + "addFilter", "updateFilter", "removeFilter", + "updateAggregation", + "addDimension", "updateDimension", "removeDimension", + "addSort", "updateSort", "removeSort", + "updateLimit" + ); + } + + setQuery(dataset_query) { this.props.setQueryFn(dataset_query); - }, + } - addDimension: function() { + addDimension() { Query.addDimension(this.props.query.query); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Add GroupBy'); - }, + } - updateDimension: function(index, dimension) { + updateDimension(index, dimension) { Query.updateDimension(this.props.query.query, dimension, index); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify GroupBy'); - }, + } - removeDimension: function(index) { + removeDimension(index) { Query.removeDimension(this.props.query.query, index); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove GroupBy'); - }, + } - updateAggregation: function(aggregationClause) { + updateAggregation(aggregationClause) { Query.updateAggregation(this.props.query.query, aggregationClause); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Aggregation', aggregationClause[0]); - }, + } + + addFilter(filter) { + let query = this.props.query.query; + Query.addFilter(query); + Query.updateFilter(query, Query.getFilters(query).length - 1, filter); - addFilter: function() { - Query.addFilter(this.props.query.query); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Add Filter'); - }, + } - updateFilter: function(index, filter) { + updateFilter(index, filter) { Query.updateFilter(this.props.query.query, index, filter); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify Filter'); - }, + } - removeFilter: function(index) { + removeFilter(index) { Query.removeFilter(this.props.query.query, index); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Filter'); - }, + } - addLimit: function() { + addLimit() { Query.addLimit(this.props.query.query); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Limit'); - }, + } - updateLimit: function(limit) { + updateLimit(limit) { if (limit) { Query.updateLimit(this.props.query.query, limit); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Limit'); @@ -102,71 +108,56 @@ export default React.createClass({ MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Limit'); } this.setQuery(this.props.query); - }, + } - addSort: function() { + addSort() { Query.addSort(this.props.query.query); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual'); - }, + } - updateSort: function(index, sort) { + updateSort(index, sort) { Query.updateSort(this.props.query.query, index, sort); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual'); - }, + } - removeSort: function(index) { + removeSort(index) { Query.removeSort(this.props.query.query, index); this.setQuery(this.props.query); MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Sort'); - }, + } - renderAdd: function(text, onClick) { - let classes = "text-grey-2 text-grey-4-hover cursor-pointer text-bold no-decoration flex align-center mx2 transition-color"; + renderAdd(text, onClick) { + let classes = "text-grey-2 text-grey-4-hover cursor-pointer text-bold no-decoration flex align-center transition-color"; return ( <a className={classes} onClick={onClick}> {this.renderAddIcon()} { text ? (<span className="ml1">{text}</span>) : (null) } </a> ) - }, + } - renderAddIcon: function () { + renderAddIcon() { return ( <IconBorder borderRadius="3px"> <Icon name="add" width="14px" height="14px" /> </IconBorder> ) - }, - - renderDbSelector: function() { - if(this.props.databases && this.props.databases.length > 1) { - return ( - <div className={this.props.querySectionClasses + ' mt1 lg-mt2'}> - <span className="Query-label">Data source:</span> - <DatabaseSelector - databases={this.props.databases} - setDatabase={this.setDatabase} - currentDatabaseId={this.props.query.database} - /> - </div> - ); - } - }, + } - renderFilters: function() { - var enabled; - var filterList; - var addFilterButton; + renderFilters() { + let enabled; + let filterList; + let addFilterButton; if (this.props.tableMetadata) { enabled = true; - var queryFilters = Query.getFilters(this.props.query.query); + let queryFilters = Query.getFilters(this.props.query.query); if (queryFilters && queryFilters.length > 0) { filterList = queryFilters.map((filter, index) => { if(index > 0) { @@ -188,31 +179,44 @@ export default React.createClass({ // TODO: proper check for isFilterComplete(filter) if (Query.canAddFilter(this.props.query.query)) { if (filterList) { - addFilterButton = this.renderAdd(null, this.addFilter); + addFilterButton = this.renderAdd(); } else { - addFilterButton = this.renderAdd("Add filters to narrow your answer", this.addFilter); + addFilterButton = this.renderAdd("Add filters to narrow your answer"); } } } else { enabled = false; - addFilterButton = this.renderAdd("Add filters to narrow your answer", this.addFilter); + addFilterButton = this.renderAdd("Add filters to narrow your answer"); } - var querySectionClasses = cx({ - "Query-section": true, - disabled: !enabled - }); return ( - <div className={querySectionClasses}> + <div className={cx("Query-section", { disabled: !enabled })}> <div className="Query-filters"> {filterList} </div> - {addFilterButton} + <div className="mx2"> + <PopoverWithTrigger ref="filterPopover" + className="PopoverBody PopoverBody--withArrow" + tetherOptions={{ + attachment: 'top center', + targetAttachment: 'bottom left', + targetOffset: '4px 14px' + }} + triggerElement={addFilterButton} + triggerClasses="flex align-center"> + <FilterPopover + isNew={true} + tableMetadata={this.props.tableMetadata} + onCommitFilter={this.addFilter} + onClose={() => this.refs.filterPopover.close()} + /> + </PopoverWithTrigger> + </div> </div> ); - }, + } - renderAggregation: function() { + renderAggregation() { // aggregation clause. must have table details available if (this.props.tableMetadata) { return ( @@ -230,9 +234,9 @@ export default React.createClass({ </div> ); } - }, + } - renderBreakouts: function() { + renderBreakouts() { var enabled; var breakoutList; var addBreakoutButton; @@ -268,6 +272,7 @@ export default React.createClass({ breakoutList.push( <FieldWidget key={index} + color="green" className="View-section-breakout SelectionModule p1" placeholder='field' field={breakout} @@ -305,9 +310,9 @@ export default React.createClass({ {addBreakoutButton} </div> ); - }, + } - renderSort: function() { + renderSort() { var sortFieldOptions; if (this.props.tableMetadata) { @@ -349,9 +354,9 @@ export default React.createClass({ </div> ); } - }, + } - renderLimit: function() { + renderLimit() { var limitOptions = [undefined, 1, 10, 25, 100, 1000].map((count) => { var name = count || "None"; var classes = cx({ @@ -367,9 +372,9 @@ export default React.createClass({ {limitOptions} </ul> ) - }, + } - renderDataSection: function() { + renderDataSection() { var isInitiallyOpen = !this.props.query.database || !this.props.query.query.source_table; return ( <DataSelector @@ -384,18 +389,18 @@ export default React.createClass({ isInitiallyOpen={isInitiallyOpen} /> ); - }, + } - renderFilterSection: function() { + renderFilterSection() { return ( <div className="GuiBuilder-section GuiBuilder-filtered-by flex align-center" ref="filterSection"> <span className="GuiBuilder-section-label Query-label">Filtered by</span> {this.renderFilters()} </div> ); - }, + } - renderViewSection: function() { + renderViewSection() { return ( <div className="GuiBuilder-section GuiBuilder-view flex align-center px1" ref="viewSection"> <span className="GuiBuilder-section-label Query-label">View</span> @@ -403,9 +408,9 @@ export default React.createClass({ {this.renderBreakouts()} </div> ); - }, + } - renderSortLimitSection: function() { + renderSortLimitSection() { var tetherOptions = { attachment: 'top right', targetAttachment: 'bottom center', @@ -439,9 +444,9 @@ export default React.createClass({ </PopoverWithTrigger> </div> ); - }, + } - componentDidUpdate: function() { + componentDidUpdate() { // HACK: magic number "5" accounts for the borders between the sections? let contentWidth = ["data", "filter", "view", "sortLimit"].reduce((acc, ref) => acc + React.findDOMNode(this.refs[`${ref}Section`]).offsetWidth, 0) + 5; let guiBuilderWidth = React.findDOMNode(this.refs.guiBuilder).offsetWidth; @@ -450,9 +455,9 @@ export default React.createClass({ if (this.state.expanded !== expanded) { this.setState({ expanded }); } - }, + } - render: function() { + render() { var classes = cx({ 'GuiBuilder': true, 'GuiBuilder--expand': this.state.expanded, @@ -475,4 +480,15 @@ export default React.createClass({ </div> ); } -}); +} + +GuiQueryEditor.propTypes = { + databases: PropTypes.array.isRequired, + query: PropTypes.object.isRequired, + tableMetadata: PropTypes.object, // can't be required, sometimes null + isShowingDataReference: PropTypes.bool.isRequired, + setQueryFn: PropTypes.func.isRequired, + setDatabaseFn: PropTypes.func.isRequired, + setSourceTableFn: PropTypes.func.isRequired, + toggleExpandCollapseFn: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/SelectionModule.react.js b/resources/frontend_client/app/query_builder/SelectionModule.react.js index bd1c5454b492e961d495a160a534ea24c8bf15ae..94a44a70908b9f902ed52e13f6f4c823a86b0a6e 100644 --- a/resources/frontend_client/app/query_builder/SelectionModule.react.js +++ b/resources/frontend_client/app/query_builder/SelectionModule.react.js @@ -245,7 +245,6 @@ export default React.createClass({ <div className="SelectionModule-trigger flex align-center"> <a className="QueryOption p1 flex align-center" onClick={this._toggleOpen}> {placeholder} - { selection ? (<Icon className="ml1" name="chevrondown" width="8px" height="8px" />) : (null) } </a> {remove} </div> diff --git a/resources/frontend_client/app/query_builder/TimeGroupingPopover.react.js b/resources/frontend_client/app/query_builder/TimeGroupingPopover.react.js new file mode 100644 index 0000000000000000000000000000000000000000..4132c6eac43a5aa81489f528b1839bd582f3814d --- /dev/null +++ b/resources/frontend_client/app/query_builder/TimeGroupingPopover.react.js @@ -0,0 +1,65 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import { parseFieldBucketing, formatBucketing } from "metabase/lib/query_time"; + +import cx from "classnames"; + +const BUCKETINGS = [ + // "default", + // "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year", + null, + // "minute-of-hour", + // "hour-of-day", + "day-of-week", + // "day-of-month", + // "day-of-year", + "week-of-year", + "month-of-year", + // "quarter-of-year", +]; + +export default class TimeGroupingPopover extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + setField(bucketing) { + this.props.onFieldChange(["datetime_field", this.props.value, "as", bucketing]); + } + + render() { + let { field } = this.props; + return ( + <div className="p2" style={{width:"250px"}}> + <h3 className="List-section-header mx2">Group time by</h3> + <ul className="py1"> + { BUCKETINGS.map((bucketing, bucketingIndex) => + bucketing == null ? + <hr style={{ "border": "none" }}/> + : + <li className={cx("List-item", { "List-item--selected": parseFieldBucketing(field) === bucketing })}> + <a className="List-item-title full px2 py1 cursor-pointer" onClick={this.setField.bind(this, bucketing)}> + {formatBucketing(bucketing)} + </a> + </li> + )} + </ul> + </div> + ); + } +} + +TimeGroupingPopover.propTypes = { + field: PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.array]), + value: PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.array]), + onFieldChange: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/FilterPopover.react.js b/resources/frontend_client/app/query_builder/filters/FilterPopover.react.js new file mode 100644 index 0000000000000000000000000000000000000000..5541dde917687ed45a68067d99cb62f7ea11a21b --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/FilterPopover.react.js @@ -0,0 +1,248 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import FieldList from "../FieldList.react"; +import OperatorSelector from "./OperatorSelector.react"; + +import DatePicker from "./pickers/DatePicker.react"; +import NumberPicker from "./pickers/NumberPicker.react"; +import SelectPicker from "./pickers/SelectPicker.react"; +import TextPicker from "./pickers/TextPicker.react"; + +import Icon from "metabase/components/Icon.react"; + +import Query from "metabase/lib/query"; +import { isDate } from "metabase/lib/schema_metadata"; +import { singularize } from "metabase/lib/formatting"; + +import cx from "classnames"; +import _ from "underscore"; + +export default class FilterPopover extends Component { + constructor(props) { + super(props); + + this.state = { + filter: (props.isNew ? [] : props.filter) + }; + + _.bindAll(this, "setField", "clearField", "setOperator", "setValues", "setFilter", "commitFilter"); + } + + commitFilter() { + this.props.onCommitFilter(this.state.filter); + this.props.onClose(); + } + + setField(fieldId) { + let { filter } = this.state; + if (filter[1] !== fieldId) { + // different field, reset the filter + filter = []; + + // update the field + filter[1] = fieldId; + + // default to the first operator + let { field } = Query.getFieldTarget(filter[1], this.props.tableMetadata); + let operator = field.valid_operators[0].name; + + filter = this._updateOperator(filter, operator); + } + this.setState({ filter }); + } + + setFilter(filter) { + this.setState({ filter }); + } + + setOperator(operator) { + let { filter } = this.state; + if (filter[0] !== operator) { + filter = this._updateOperator(filter, operator); + this.setState({ filter }); + } + } + + setValue(index, value) { + let { filter } = this.state; + filter[index + 2] = value; + this.setState({ filter: filter }); + } + + setValues(values) { + let { filter } = this.state; + this.setState({ filter: filter.slice(0,2).concat(values) }); + } + + _updateOperator(oldFilter, operatorName) { + let { field } = Query.getFieldTarget(oldFilter[1], this.props.tableMetadata); + let operator = field.operators_lookup[operatorName]; + let oldOperator = field.operators_lookup[oldFilter[0]]; + + // update the operator + let filter = [operatorName, oldFilter[1]]; + + if (operator) { + for (let i = 0; i < operator.fields.length; i++) { + if (operator.defaults && operator.defaults[i] !== undefined) { + filter.push(operator.defaults[i]); + } else { + filter.push(undefined); + } + } + if (oldOperator) { + // copy over values of the same type + for (let i = 0; i < oldFilter.length - 2; i++) { + let field = operator.multi ? operator.fields[0] : operator.fields[i]; + let oldField = oldOperator.multi ? oldOperator.fields[0] : oldOperator.fields[i]; + if (field && oldField && field.type === oldField.type && oldFilter[i + 2] !== undefined) { + filter[i + 2] = oldFilter[i + 2]; + } + } + } + } + return filter; + } + + isValid() { + let { filter } = this.state; + // has an operator name and field id + if (filter[0] == null || !Query.isValidField(filter[1])) { + return false; + } + // field/operator combo is valid + let { field } = Query.getFieldTarget(filter[1], this.props.tableMetadata); + let operator = field.operators_lookup[filter[0]]; + if (operator) { + // has the mininum number of arguments + if (filter.length - 2 < operator.fields.length) { + return false; + } + } + // arguments are non-null/undefined + for (var i = 2; i < filter.length; i++) { + if (filter[i] == null) { + return false; + } + } + + return true; + } + + clearField() { + let { filter } = this.state; + filter[1] = null; + this.setState({ filter }); + } + + renderPicker(filter, field) { + let operator = field.operators_lookup[filter[0]]; + return operator.fields.map((operatorField, index) => { + let values, onValuesChange; + let placeholder = operator.placeholders && operator.placeholders[index] || undefined; + if (operator.multi) { + values = this.state.filter.slice(2); + onValuesChange = (values) => this.setValues(values); + } else { + values = [this.state.filter[2 + index]]; + onValuesChange = (values) => this.setValue(index, values[0]); + } + if (operatorField.type === "select") { + return ( + <SelectPicker + options={operatorField.values} + values={values} + onValuesChange={onValuesChange} + placeholder={placeholder} + multi={operator.multi} + /> + ); + } else if (operatorField.type === "text") { + return ( + <TextPicker + values={values} + onValuesChange={onValuesChange} + placeholder={placeholder} + multi={operator.multi} + /> + ); + } else if (operatorField.type === "number") { + return ( + <NumberPicker + values={values} + onValuesChange={onValuesChange} + placeholder={placeholder} + multi={operator.multi} + /> + ); + } + return <span>not implemented {operatorField.type} {operator.multi ? "true" : "false"}</span>; + }); + } + + render() { + let { filter } = this.state; + if (filter[1] == undefined) { + return ( + <div className="FilterPopover"> + <FieldList + field={this.state.filter[1]} + fieldOptions={Query.getFieldOptions(this.props.tableMetadata.fields, true)} + tableName={this.props.tableMetadata.display_name} + onFieldChange={this.setField} + className="text-purple" + /> + </div> + ); + } else { + let { filter } = this.state; + let { table, field } = Query.getFieldTarget(filter[1], this.props.tableMetadata); + + return ( + <div style={{width: 300}}> + <div className="FilterPopover-header text-grey-3 p1 mt1 flex align-center"> + <a className="cursor-pointer flex align-center" onClick={this.clearField}> + <Icon name="chevronleft" width="18" height="18"/> + <h3 className="inline-block">{singularize(table.display_name)}</h3> + </a> + <h3 className="mx1">-</h3> + <h3 className="text-default">{field.display_name}</h3> + </div> + { isDate(field) ? + <DatePicker + filter={filter} + onFilterChange={this.setFilter} + onOperatorChange={this.setOperator} + /> + : + <div> + <OperatorSelector + filter={filter} + field={field} + onOperatorChange={this.setOperator} + /> + { this.renderPicker(filter, field) } + </div> + } + <div className="FilterPopover-footer p1"> + <button className={cx("Button Button--purple full", { "disabled": !this.isValid() })} onClick={this.commitFilter}> + {this.props.isNew ? "Add filter" : "Update filter"} + </button> + </div> + </div> + ); + } + } +} + +FilterPopover.propTypes = { + isNew: PropTypes.bool, + filter: PropTypes.array, + onCommitFilter: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +FilterPopover.defaultProps = { +}; diff --git a/resources/frontend_client/app/query_builder/filters/FilterWidget.react.js b/resources/frontend_client/app/query_builder/filters/FilterWidget.react.js new file mode 100644 index 0000000000000000000000000000000000000000..56b00f9a961dff47e847e7814e4a1dd59c52f2ae --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/FilterWidget.react.js @@ -0,0 +1,161 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.react"; +import FieldName from '../FieldName.react'; +import Popover from "metabase/components/Popover.react"; +import FilterPopover from "./FilterPopover.react"; + +import Query from "metabase/lib/query"; +import { generateTimeFilterValuesDescriptions } from "metabase/lib/query_time"; +import { isDate } from "metabase/lib/schema_metadata"; + +import cx from "classnames"; +import _ from "underscore"; + +export default class FilterWidget extends Component { + constructor(props) { + super(props); + + this.state = { + isOpen: this.props.filter[0] == undefined + }; + + _.bindAll(this, "open", "close", "removeFilter"); + } + + componentWillMount() { + this.componentWillReceiveProps(this.props); + } + + componentWillReceiveProps(newProps) { + let { filter } = newProps; + let [operator, field, ...values] = filter; + + let target = Query.getFieldTarget(field, newProps.tableMetadata); + let fieldDef = target && target.field; + let operatorDef = fieldDef && fieldDef.operators_lookup[operator]; + + if (!operatorDef) { + operatorDef = fieldDef && fieldDef.operators_lookup['=']; + } + + this.setState({ + field: field, + fieldDef: fieldDef, + operator: operator, + operatorDef: operatorDef, + values: values + }); + } + + removeFilter() { + this.props.removeFilter(this.props.index); + } + + open() { + this.setState({ isOpen: true }); + } + + close() { + this.setState({ isOpen: false }); + } + + renderField() { + return ( + <FieldName + className="Filter-section Filter-section-field" + field={this.state.field} + fieldOptions={Query.getFieldOptions(this.props.tableMetadata.fields, true)} + onClick={this.open} + /> + ); + } + + renderOperator() { + var { operatorDef } = this.state; + return ( + <div className="Filter-section Filter-section-operator" onClick={this.open}> + + <a className="QueryOption flex align-center">{operatorDef && operatorDef.moreVerboseName}</a> + </div> + ); + } + + renderValues() { + let { operatorDef, fieldDef, values } = this.state; + + if (operatorDef.multi && values.length > 1) { + values = [values.length + " selections"]; + } + + if (isDate(fieldDef)) { + values = generateTimeFilterValuesDescriptions(this.props.filter); + } + + return values.map((value, valueIndex) => { + var valueString = value != null ? value.toString() : null; + return ( + <div key={valueIndex} className="Filter-section Filter-section-value" onClick={this.open}> + <span className="QueryOption">{valueString}</span> + </div> + ); + }); + } + + renderPopover() { + if (this.state.isOpen) { + var tetherOptions = { + attachment: 'top left', + targetAttachment: 'bottom left', + targetOffset: '10px 0' + }; + + return ( + <Popover + ref="filterPopover" + className="PopoverBody PopoverBody--withArrow FilterPopover" + isInitiallyOpen={this.state.field === null} + tetherOptions={tetherOptions} + onClose={this.close} + > + <FilterPopover + filter={this.props.filter} + tableMetadata={this.props.tableMetadata} + onCommitFilter={(filter) => this.props.updateFilter(this.props.index, filter)} + onClose={this.close} + /> + </Popover> + ); + } + } + + render() { + return ( + <div className={cx("Query-filter px1", { "selected": this.state.isOpen })}> + <div className="ml1"> + <div className="flex align-center" style={{"padding": "0.5em", "paddingTop": "0.3em", "paddingBottom": "0.3em"}}> + {this.renderField()} + {this.renderOperator()} + </div> + <div className="flex align-center"> + {this.renderValues()} + </div> + {this.renderPopover()} + </div> + <a className="text-grey-2 no-decoration px1 flex align-center" href="#" onClick={this.removeFilter}> + <Icon name='close' width="14px" height="14px" /> + </a> + </div> + ); + } +} + +FilterWidget.propTypes = { + filter: PropTypes.array.isRequired, + tableMetadata: PropTypes.object.isRequired, + index: PropTypes.number.isRequired, + updateFilter: PropTypes.func.isRequired, + removeFilter: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/OperatorSelector.react.js b/resources/frontend_client/app/query_builder/filters/OperatorSelector.react.js new file mode 100644 index 0000000000000000000000000000000000000000..c0f2bfa47d2a5e51690c9fa6d3df521179ca7066 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/OperatorSelector.react.js @@ -0,0 +1,57 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.react"; + +import cx from "classnames"; + +export default class OperatorSelector extends Component { + constructor(props) { + super(props); + this.state = { + expanded: false + }; + } + + render() { + let { field, filter } = this.props; + let { expanded } = this.state; + + let operators = field.valid_operators; + + let defaultOperators = operators.filter(o => !o.advanced); + let expandedOperators = operators.filter(o => o.advanced); + + let visibleOperators = defaultOperators; + if (expanded) { + visibleOperators = visibleOperators.concat(expandedOperators); + } + + return ( + <div className="border-bottom p1"> + { visibleOperators.map(operator => + <button + key={operator.name} + className={cx("Button Button-normal Button--medium mr1 mb1", { "Button--purple": operator.name === filter[0] })} + onClick={() => this.props.onOperatorChange(operator.name)} + > + {operator.verboseName} + </button> + )} + { !expanded && expandedOperators.length > 0 ? + <div className="text-grey-3 cursor-pointer" onClick={() => this.setState({ expanded: true })}> + <Icon className="px1" name="chevrondown" width="14" height="14" /> + More Options + </div> + : null } + </div> + ); + } +} + +OperatorSelector.propTypes = { + filter: PropTypes.array.isRequired, + field: PropTypes.object.isRequired, + onOperatorChange: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/DatePicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/DatePicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..473f1006bb93a99a0b235b80f014bf595a024173 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/DatePicker.react.js @@ -0,0 +1,59 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import SpecificDatePicker from "./SpecificDatePicker.react"; +import RelativeDatePicker from "./RelativeDatePicker.react"; + +import cx from "classnames"; + +export default class DatePicker extends Component { + constructor(props) { + super(props); + this.state = { + pane: this._detectPane(props) + }; + } + + _detectPane(props) { + if (props.filter[0] !== "TIME_INTERVAL" && typeof props.filter[2] === "string") { + return "specific"; + } else { + return "relative"; + } + } + + selectPane(pane) { + this.props.onFilterChange([null, this.props.filter[1]]); + this.setState({ pane }); + } + + render() { + return ( + <div> + <div className="p1 border-bottom"> + <button className={cx("Button Button--medium mr1", { "Button--purple": this.state.pane === "relative" })} onClick={this.selectPane.bind(this, "relative")}>Relative date</button> + <button className={cx("Button Button--medium", { "Button--purple": this.state.pane === "specific" })} onClick={this.selectPane.bind(this, "specific")}>Specific date</button> + </div> + { this.state.pane === "relative" ? + <RelativeDatePicker + filter={this.props.filter} + onFilterChange={this.props.onFilterChange} + /> + : + <SpecificDatePicker + filter={this.props.filter} + onFilterChange={this.props.onFilterChange} + onOperatorChange={this.props.onOperatorChange} + /> + } + </div> + ) + } +} + +DatePicker.propTypes = { + filter: PropTypes.array.isRequired, + onFilterChange: PropTypes.func.isRequired, + onOperatorChange: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/NumberPicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/NumberPicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..cd0697a38a8be72c54cf5ae6399da4050bab7dd5 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/NumberPicker.react.js @@ -0,0 +1,51 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import TextPicker from "./TextPicker.react.js"; + +export default class NumberPicker extends Component { + constructor(props) { + super(props); + this.state = { + values: props.values, + validations: this._validate(props.values) + } + } + + _validate(values) { + return values.map(v => v === undefined || !isNaN(v)); + } + + onValuesChange(stringValues) { + let values = stringValues.map(v => parseFloat(v)) + this.props.onValuesChange(values.map(v => isNaN(v) ? null : v)); + this.setState({ + values: stringValues, + validations: this._validate(values) + }); + } + + render() { + return ( + <TextPicker + {...this.props} + values={this.state.values.slice(0, this.props.values.length)} + validations={this.state.validations} + onValuesChange={(values) => this.onValuesChange(values)} + /> + ); + } +} + +NumberPicker.propTypes = { + values: PropTypes.array.isRequired, + onValuesChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + validations: PropTypes.array, + multi: PropTypes.bool +}; + +NumberPicker.defaultProps = { + placeholder: "Enter desired number" +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/RelativeDatePicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/RelativeDatePicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..d47588fb108cc2cab2e4cc57b1026f76e93f69b6 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/RelativeDatePicker.react.js @@ -0,0 +1,102 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import cx from "classnames"; +import _ from "underscore"; + +const SHORTCUTS = [ + { name: "Today", operator: ["=", "<", ">"], values: [["relative_datetime", "current"]]}, + { name: "Yesterday", operator: ["=", "<", ">"], values: [["relative_datetime", -1, "day"]]}, + { name: "Past 7 days", operator: "TIME_INTERVAL", values: [-7, "day"]}, + { name: "Past 30 days", operator: "TIME_INTERVAL", values: [-30, "day"]} +]; + +const RELATIVE_SHORTCUTS = { + "Last": [ + { name: "Week", operator: "TIME_INTERVAL", values: ["last", "week"]}, + { name: "Month", operator: "TIME_INTERVAL", values: ["last", "month"]}, + { name: "Year", operator: "TIME_INTERVAL", values: ["last", "year"]} + ], + "This": [ + { name: "Week", operator: "TIME_INTERVAL", values: ["current", "week"]}, + { name: "Month", operator: "TIME_INTERVAL", values: ["current", "month"]}, + { name: "Year", operator: "TIME_INTERVAL", values: ["current", "year"]} + ] +}; + +export default class RelativeDatePicker extends Component { + constructor(props) { + super(props); + + _.bindAll(this, "isSelectedShortcut", "onSetShortcut"); + } + + isSelectedShortcut(shortcut) { + let { filter } = this.props; + return ( + (Array.isArray(shortcut.operator) ? _.contains(shortcut.operator, filter[0]): filter[0] === shortcut.operator ) && + _.isEqual(filter.slice(2), shortcut.values) + ); + } + + onSetShortcut(shortcut) { + let { filter } = this.props; + let operator; + if (Array.isArray(shortcut.operator)) { + if (_.contains(shortcut.operator, filter[0])) { + operator = filter[0]; + } else { + operator = shortcut.operator[0]; + } + } else { + operator = shortcut.operator; + } + this.props.onFilterChange([operator, filter[1], ...shortcut.values]) + } + + render() { + return ( + <div className="p1 pt2"> + <section> + { SHORTCUTS.map((s, index) => + <span key={index} className={cx("inline-block half pb1", { "pr1": index % 2 === 0 })}> + <button + key={index} + className={cx("Button Button-normal Button--medium text-normal text-centered full", { "Button--purple": this.isSelectedShortcut(s) })} + onClick={() => this.onSetShortcut(s)} + > + {s.name} + </button> + </span> + )} + </section> + {Object.keys(RELATIVE_SHORTCUTS).map(sectionName => + <section key={sectionName}> + <div style={{}} className="border-bottom text-uppercase flex layout-centered mb2"> + <h6 style={{"position": "relative", "backgroundColor": "white", "top": "6px" }} className="px2"> + {{sectionName}} + </h6> + </div> + <div className="flex"> + { RELATIVE_SHORTCUTS[sectionName].map((s, index) => + <button + key={index} + className={cx("Button Button-normal Button--medium flex-full mb1", { "Button--purple": this.isSelectedShortcut(s), "mr1": index !== RELATIVE_SHORTCUTS[sectionName].length - 1 })} + onClick={() => this.onSetShortcut(s)} + > + {s.name} + </button> + )} + </div> + </section> + )} + </div> + ); + } +} + +RelativeDatePicker.propTypes = { + filter: PropTypes.array.isRequired, + onFilterChange: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/SelectPicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/SelectPicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..9630bb4c457914fc5434ba242f0db07364f2efd2 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/SelectPicker.react.js @@ -0,0 +1,91 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import CheckBox from 'metabase/components/CheckBox.react'; + +import { capitalize } from "metabase/lib/formatting"; + +import cx from "classnames"; + +export default class SelectPicker extends Component { + selectValue(key, selected) { + let values; + if (this.props.multi) { + values = this.props.values.slice().filter(v => v != null); + } else { + values = [] + } + if (selected) { + values.push(key); + } else { + values = values.filter(v => v !== key); + } + this.props.onValuesChange(values); + } + + nameForOption(option) { + if (option.name === "") { + return "Empty"; + } else if (typeof option.name === "string") { + return option.name; + } else { + return capitalize(String(option.name)); + } + } + + render() { + let { values, options, placeholder, multi } = this.props; + + let checked = {}; + for (let value of values) { + checked[value] = true; + } + + return ( + <div className="px1 pt1" style={{maxHeight: '400px', overflowY: 'scroll'}}> + { placeholder ? + <h5>{placeholder}</h5> + : null } + { multi ? + <ul> + {options.map((option, index) => + <li key={index}> + <label className="flex align-center cursor-pointer p1" onClick={() => this.selectValue(option.key, !checked[option.key])}> + <CheckBox checked={checked[option.key]} /> + <h4 className="ml1">{this.nameForOption(option)}</h4> + </label> + </li> + )} + </ul> + : + <div className="flex flex-wrap py1"> + {options.map((option, index) => + <div className="half" style={{ padding: "0.15em" }}> + <button + style={{ height: "95px" }} + className={cx("full rounded bordered border-purple text-centered text-bold", { + "text-purple bg-white": values[0] !== option.key, + "text-white bg-purple-light": values[0] === option.key + })} + onClick={() => this.selectValue(option.key, true)} + > + {this.nameForOption(option)} + </button> + </div> + )} + </div> + } + + </div> + ); + } +} + +SelectPicker.propTypes = { + options: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + onValuesChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + multi: PropTypes.bool +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/SpecificDatePicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/SpecificDatePicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..46657eea8742e038021515cfe8cae74f338e50c2 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/SpecificDatePicker.react.js @@ -0,0 +1,75 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; + +import Calendar from '../../Calendar.react'; +import { computeFilterTimeRange } from "metabase/lib/query_time"; + +import _ from "underscore"; +import cx from "classnames"; +import moment from "moment"; + +export default class SpecificDatePicker extends Component { + constructor(props) { + super(props); + + _.bindAll(this, "onChange"); + } + + toggleOperator(operator) { + if (this.props.filter[0] === operator) { + this.props.onOperatorChange("="); + } else { + this.props.onOperatorChange(operator); + } + } + + onChange(start, end) { + let { filter } = this.props; + if (end) { + this.props.onFilterChange(["BETWEEN", filter[1], start, end]); + } else { + let operator = _.contains(["=", "<", ">"], filter[0]) ? filter[0] : "="; + this.props.onFilterChange([operator, filter[1], start]); + } + } + + render() { + let { filter } = this.props; + let [start, end] = computeFilterTimeRange(filter); + + let initial; + if (start && end) { + initial = Math.abs(moment().diff(start)) < Math.abs(moment().diff(end)) ? start : end; + } else if (start) { + initial = start; + } + + if (start && start.isSame(end, "day")) { + end = null; + } + + return ( + <div> + <div className="mx1 mt1"> + <Calendar + initial={initial} + selected={start} + selectedEnd={end} + onChange={this.onChange} + /> + <div className={cx("py1", { "disabled": filter[2] == null })}> + <span className={cx("inline-block text-centered text-purple-hover half py1 border-right", { "text-purple": filter[0] === "<" })} onClick={this.toggleOperator.bind(this, "<")}><< All before</span> + <span className={cx("inline-block text-centered text-purple-hover half py1", { "text-purple": filter[0] === ">" })} onClick={this.toggleOperator.bind(this, ">")}>All after >></span> + </div> + </div> + </div> + ) + } +} + +SpecificDatePicker.propTypes = { + filter: PropTypes.array.isRequired, + onFilterChange: PropTypes.func.isRequired, + onOperatorChange: PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/query_builder/filters/pickers/TextPicker.react.js b/resources/frontend_client/app/query_builder/filters/pickers/TextPicker.react.js new file mode 100644 index 0000000000000000000000000000000000000000..43aea335bcc2165303a69b9838dd1a9f82db4db9 --- /dev/null +++ b/resources/frontend_client/app/query_builder/filters/pickers/TextPicker.react.js @@ -0,0 +1,75 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.react"; +import cx from "classnames"; + +export default class TextPicker extends Component { + + addValue() { + let values = this.props.values.slice(); + values.push(null); + this.props.onValuesChange(values); + } + + removeValue(index) { + let values = this.props.values.slice(); + values.splice(index, 1); + this.props.onValuesChange(values); + } + + setValue(index, value) { + let values = this.props.values.slice(); + values[index] = value; + this.props.onValuesChange(values); + } + + render() { + let { values, validations, multi } = this.props; + + return ( + <div> + <ul> + {values.map((value, index) => + <li className="px1 pt1 relative"> + <input + className={cx("input block full border-purple", { "border-error": validations[index] === false })} + type="text" + value={value} + onChange={(e) => this.setValue(index, e.target.value)} + placeholder={this.props.placeholder} + autoFocus={true} + /> + { index > 0 ? + <span className="absolute top right"> + <Icon name="close" className="cursor-pointer" width="16" height="16" onClick={() => this.removeValue(index)}/> + </span> + : null } + </li> + )} + </ul> + { multi ? + <div className="p1"> + { values[values.length - 1] != null ? + <a className="text-underline cursor-pointer" onClick={() => this.addValue()}>Add another value</a> + : null } + </div> + : null } + </div> + ); + } +} + +TextPicker.propTypes = { + values: PropTypes.array.isRequired, + onValuesChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + validations: PropTypes.array, + multi: PropTypes.bool +}; + +TextPicker.defaultProps = { + validations: [], + placeholder: "Enter desired text" +} diff --git a/resources/frontend_client/test/unit/lib/query_time.spec.js b/resources/frontend_client/test/unit/lib/query_time.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5f3276c4b65f82374a346e211c2cf623bbfbca9e --- /dev/null +++ b/resources/frontend_client/test/unit/lib/query_time.spec.js @@ -0,0 +1,112 @@ +'use strict'; +/*eslint-env jasmine */ + +import moment from "moment"; + +import { expandTimeIntervalFilter, computeFilterTimeRange, absolute } from 'metabase/lib/query_time'; + +describe('query_time', () => { + describe('expandTimeIntervalFilter', () => { + it('translate ["current" "month"] correctly', () => { + expect( + JSON.stringify(expandTimeIntervalFilter(["TIME_INTERVAL", 100, "current", "month"])) + ).toBe( + JSON.stringify(["=", ["datetime_field", 100, "as", "month"], ["relative_datetime", "current"]]) + ); + }); + it('translate [-30, "day"] correctly', () => { + expect( + JSON.stringify(expandTimeIntervalFilter(["TIME_INTERVAL", 100, -30, "day"])) + ).toBe( + JSON.stringify(["BETWEEN", ["datetime_field", 100, "as", "day"], ["relative_datetime", -31, "day"], ["relative_datetime", -1, "day"]]) + ); + }); + }); + + describe('absolute', () => { + it ('should pass through absolute dates', () => { + expect( + absolute("2009-08-07T06:05:04Z").format("YYYY-MM-DD HH:mm:ss") + ).toBe( + moment("2009-08-07 06:05:04Z").format("YYYY-MM-DD HH:mm:ss") + ); + }); + + it ('should convert relative_datetime "current"', () => { + expect( + absolute(["relative_datetime", "current"]).format("YYYY-MM-DD HH") + ).toBe( + moment().format("YYYY-MM-DD HH") + ); + }); + + it ('should convert relative_datetime -1 "month"', () => { + expect( + absolute(["relative_datetime", -1, "month"]).format("YYYY-MM-DD HH") + ).toBe( + moment().subtract(1, "month").format("YYYY-MM-DD HH") + ); + }); + }); + + describe('computeFilterTimeRange', () => { + describe('absolute dates', () => { + it ('should handle "="', () => { + let [start, end] = computeFilterTimeRange(["=", 1, "2009-08-07"]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-07 00:00:00"); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-07 23:59:59"); + }); + it ('should handle "<"', () => { + let [start, end] = computeFilterTimeRange(["<", 1, "2009-08-07"]); + expect(start.year()).toBeLessThan(-10000); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-07 00:00:00"); + }); + it ('should handle ">"', () => { + let [start, end] = computeFilterTimeRange([">", 1, "2009-08-07"]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-07 23:59:59"); + expect(end.year()).toBeGreaterThan(10000); + }); + it ('should handle "BETWEEN"', () => { + let [start, end] = computeFilterTimeRange(["BETWEEN", 1, "2009-08-07", "2009-08-09"]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-07 00:00:00"); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual("2009-08-09 23:59:59"); + }); + }) + + describe('relative dates', () => { + it ('should handle "="', () => { + let [start, end] = computeFilterTimeRange(["=", 1, ["relative_datetime", "current"]]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().format("YYYY-MM-DD 00:00:00")); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().format("YYYY-MM-DD 23:59:59")); + }); + it ('should handle "<"', () => { + let [start, end] = computeFilterTimeRange(["<", 1, ["relative_datetime", "current"]]); + expect(start.year()).toBeLessThan(-10000); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().format("YYYY-MM-DD 00:00:00")); + }); + it ('should handle ">"', () => { + let [start, end] = computeFilterTimeRange([">", 1, ["relative_datetime", "current"]]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().format("YYYY-MM-DD 23:59:59")); + expect(end.year()).toBeGreaterThan(10000); + }); + it ('should handle "BETWEEN"', () => { + let [start, end] = computeFilterTimeRange(["BETWEEN", 1, ["relative_datetime", -1, "day"], ["relative_datetime", 1, "day"]]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().subtract(1, "day").format("YYYY-MM-DD 00:00:00")); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().add(1, "day").format("YYYY-MM-DD 23:59:59")); + }); + }); + + describe('TIME_INTERVAL', () => { + it ('should handle "Past x days"', () => { + let [start, end] = computeFilterTimeRange(["TIME_INTERVAL", 1, -7, "day"]); + expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().subtract(8, "day").format("YYYY-MM-DD 00:00:00")); + expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().subtract(1, "day").format("YYYY-MM-DD 23:59:59")); + }); + // it ('should handle "last week"', () => { + // let [start, end] = computeFilterTimeRange(["TIME_INTERVAL", 1, "last", "week"]); + // expect(start.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().subtract(1, "week").startOf("week").format("YYYY-MM-DD 00:00:00")); + // expect(end.format("YYYY-MM-DD HH:mm:ss")).toEqual(moment().subtract(1, "week").endOf("week")..format("YYYY-MM-DD 23:59:59")); + // }); + }); + }); +}); diff --git a/resources/frontend_client/test/unit/lib/schema_metadata.spec.js b/resources/frontend_client/test/unit/lib/schema_metadata.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5464a2a9c6c81290be30c757c58b0645a37b425b --- /dev/null +++ b/resources/frontend_client/test/unit/lib/schema_metadata.spec.js @@ -0,0 +1,65 @@ +'use strict'; +/*eslint-env jasmine */ + +import { + parseBaseType, + parseSpecialType, + getUmbrellaType, + TIME, + STRING, + NUMBER, + BOOL, + LOCATION +} from '../../../app/lib/schema_metadata'; + +/* i'm a pretend latitude field */ +const specialField = { + base_type: 'FloatField', + special_type: 'latitude' +} + +describe('schema_metadata', () => { + + describe('parseBaseType', () => { + it('should know a date', () => { + expect(parseBaseType('DateField')).toEqual(TIME) + expect(parseBaseType('DateTimeField')).toEqual(TIME) + expect(parseBaseType('TimeField')).toEqual(TIME) + }); + it('should know a number', () => { + expect(parseBaseType('BigIntegerField')).toEqual(NUMBER) + expect(parseBaseType('IntegerField')).toEqual(NUMBER) + expect(parseBaseType('FloatField')).toEqual(NUMBER) + expect(parseBaseType('DecimalField')).toEqual(NUMBER) + }); + it('should know a string', () => { + expect(parseBaseType('CharField')).toEqual(STRING) + expect(parseBaseType('TextField')).toEqual(STRING) + }); + it('should know a bool', () => { + expect(parseBaseType('BooleanField')).toEqual(BOOL) + }); + it('should know what it doesn\'t know', () => { + expect(parseBaseType('DERP DERP DERP')).toEqual(undefined) + }); + }); + + describe('parseSpecialType', () => { + it('should know a date', () => { + expect(parseSpecialType('timestamp_seconds')).toEqual(TIME) + expect(parseSpecialType('timestamp_milliseconds')).toEqual(TIME) + }); + it('should know a location', () => { + expect(parseSpecialType('city')).toEqual(LOCATION) + expect(parseSpecialType('country')).toEqual(LOCATION) + expect(parseSpecialType('latitude')).toEqual(LOCATION) + expect(parseSpecialType('longitude')).toEqual(LOCATION) + }); + }); + + describe('getUmbrellaType', () => { + it('should parse a special type if both types are present', () => { + expect(getUmbrellaType(specialField)).toEqual(LOCATION) + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 9bc87cd1925169de8f799f0aa12569e749c9e3e3..f68f3c08ae5fb2e24ad689b68a35d9a9e5c64f20 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -68,8 +68,8 @@ var config = module.exports = { { test: /\.js$/, exclude: /node_modules/, loader: 'babel', query: { cacheDirectory: '.babel_cache', optional: BABEL_FEATURES }}, { test: /\.js$/, exclude: /node_modules|\.spec\.js/, loader: 'eslint' }, // CSS - { test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader?-restructuring&compatibility!cssnext-loader') } - // { test: /\.css$/, loader: 'style-loader!css-loader!cssnext-loader' } + { test: /\.css$/, loader: ExtractTextPlugin.extract('style', 'css?-restructuring&compatibility!cssnext') } + // { test: /\.css$/, loader: 'style!css!cssnext' } ], noParse: [ /node_modules\/(angular|ng-|ace|react-onclickoutside|moment|underscore|jquery|d3)/ // doesn't include 'crossfilter', 'dc', and 'tether' due to use of 'require' @@ -162,6 +162,8 @@ if (NODE_ENV === "hot") { { test: /\.react.js$/, exclude: /node_modules/, loaders: ['react-hot', 'babel?'+BABEL_FEATURES.map(function(f) { return 'optional[]='+f; }).join('&')] } ); + config.module.loaders[config.module.loaders.length - 1].loader = 'style!css?-restructuring&compatibility!cssnext'; + config.plugins.unshift( new webpack.NoErrorsPlugin() );