diff --git a/.eslintrc b/.eslintrc index 7ff9ccf95b433cefef37199201f6471afb0d9bda..f522a534d6aba867c232b5e9876a9b7e0583b416 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "rules": { "strict": [2, "never"], "no-undef": 2, - "no-unused-vars": [1, {"vars": "all", "args": "none", "varsIgnorePattern": "React|PropTypes|Component"}], + "no-unused-vars": [1, {"vars": "all", "args": "none", "varsIgnorePattern": "^(React|PropTypes|Component)$"}], "import/no-commonjs": 1, "quotes": 0, "camelcase": 0, diff --git a/docs/operations-guide/start.md b/docs/operations-guide/start.md index 6440a074a198f78d7ba0826070dfe1f4b06ddf6b..e405a26d087690332e9ea1fb216a4df1467c929a 100644 --- a/docs/operations-guide/start.md +++ b/docs/operations-guide/start.md @@ -70,15 +70,33 @@ Step-by-step instructions on how to upgrade Metabase running on Heroku. # Troubleshooting Common Problems -### Metabase fails to startup +### Metabase fails to start due to database locks -Sometimes Metabase will fail to complete its startup due to a database lock that was not cleared properly. +Sometimes Metabase will fail to complete its startup due to a database lock that was not cleared properly. The error message will look something like: + + liquibase.exception.DatabaseException: liquibase.exception.LockException: Could not acquire change log lock. When this happens, go to a terminal where Metabase is installed and run: java -jar metabase.jar migrate release-locks -in the command line to manually clear the locks. Then restart your Metabase instance. +in the command line to manually clear the locks. Then restart your Metabase instance. + +### Metabase fails to start due to OutOfMemoryErrors + +On Java 7, Metabase may fail to launch with a message like + + java.lang.OutOfMemoryError: PermGen space + +or one like + + Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler + +If this happens, setting a few JVM options should fix your issue: + + java -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC -XX:MaxPermSize=256m -jar target/uberjar/metabase.jar + +Alternatively, you can upgrade to Java 8 instead, which will fix the issue as well. # Configuring the Metabase Application Database diff --git a/docs/users-guide/04-visualizing-results.md b/docs/users-guide/04-visualizing-results.md index d099c53e611c081814d761f587957480d8ef5f67..39722528142ef73a586b92cd58b7849c435c6ff1 100644 --- a/docs/users-guide/04-visualizing-results.md +++ b/docs/users-guide/04-visualizing-results.md @@ -12,6 +12,7 @@ In Metabase, an answer to a question can be visualized in a number of ways: * Area chart * Scatterplot or bubble chart * Pie/donut chart +* Funnel * Map To change how the answer to your question is displayed, click on the Visualization dropdown menu beneath the question builder bar. @@ -29,14 +30,26 @@ Each visualization type has its own advanced options you can tweak. Just click t #### Numbers This option is for displaying a single number, nice and big. The options for numbers include adding character prefixes or suffixes to it (so you can do things like put a currency symbol in front or a percent at the end), setting the number of decimal places you want to include, and multiplying your result by a number (like if you want to multiply a decimal by 100 to make it look like a percent). + + #### Progress bars Progress bars are for comparing a single number result to a goal value that you input. Open up the chart options for your progress bar to choose a goal for it, and Metabase will show you how far away your question's current result is from the goal. + + #### Tables -The Table option is good for looking at tabular data (duh), or for lists of things like users. The options allow you to hide and rearrange fields in the table you're looking at. +The Table option is good for looking at tabular data (duh), or for lists of things like users. The options allow you to hide and rearrange fields in the table you're looking at. If your table is a result that contains one metric and two dimensions, Metabase will also automatically pivot your table, like in the example below (the example shows the count of orders grouped by the review rating for that order and the category of the product that was purchased; you can tell it's pivoted because the grouping field values are all in the first column and first row). You can turn this behavior off in the chart settings. + + #### Line, bar, and area charts -Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a metric grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). Area charts are useful when comparing the the proportions between two metrics over time. Both bar and area charts can be stacked. +Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a metric grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). + + + +Area charts are useful when comparing the the proportions between two metrics over time. Both bar and area charts can be stacked. + + These three charting types have very similar options, which are broken up into the following: @@ -52,18 +65,34 @@ If you have a third numeric field, you can also create a bubble chart. Select th Scatterplots and bubble charts also have similar chart options as line, bar, and area charts. + + #### Pie or donut charts A pie or donut chart can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. If you have more than a few breakouts, like users by country, it's usually better to use a bar chart so that your users can more easily compare the relative sizes of each bar. The options for pie charts let you choose which field to use as your measurement, and which one to use for the dimension (i.e., the pie slices). You can also customize the pie chart's legend, whether or not to show each slice's percent of the whole in the legend, and the minimum size a slice needs to be in order for it to be displayed. + + +#### Funnel +Funnels are commonly used in e-commerce or sales to visualize how many customers are present within each step of a checkout flow or sales cycle. At their most general, funnels show you values broken out by steps, and the percent decrease between each successive step. To create a funnel in Metabase, you'll need to have a table with at least two columns: one column that contains the metric you're interested in, and another that contains the funnel's steps. + +For example, I might have an Opportunities table, and I could create a question that gives me the number of sales leads broken out by a field that contains stages such as `Prospecting`, `Qualification`, `Proposal`, `Negotiation`, and `Closed`. In this example, the percentages shown along the x-axis tell you what percent of the total starting opportunities are still present at each subsequent step; so 18.89% of our total opportunities have made it all the way to being closed deals. The number below each percent is the actual value of the count at that step — in our example, the actual number of opportunities that are currently at each step. Together, these numbers help you figure out where you're losing your customers or users. + + + #### Maps When you select the Map visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result set you're currently looking at. Here are the maps that Metabase uses: * **United States Map** — Creating a map of the United States from your data requires your results to contain a column field with states. This lets you do things like visualize the count of your users broken out by state, with darker states representing more users. * **Country Map** — To visualize your results in the format of a map of the world broken out by country, your result must contain a field with countries. (E.g., count of users by country.) + + + * **Pin Map** — If your table contains a latitude and longitude field, Metabase will try to display it as a pin map of the world. This will put one pin on the map for each row in your table, based on the latitude and longitude fields. You can try this with the Sample Dataset that's included in Metabase: start a new question and select the People table, use `raw data` for your view, and choose the Map option for your visualization. you'll see a map of the world, with each dot representing the latitude and longitude coordinates of a single person from the People table. + + When you open up the Map options, you can manually switch between a region map (i.e., United States or world) and a pin map. If you're using a region map, you can also choose which field to use as the measurement, and which to use as the region (i.e. State or Country). Metabase now also allows administrators to add custom region maps via GeoJSON files through the Metabase Admin Panel. diff --git a/docs/users-guide/images/visualizations/area.png b/docs/users-guide/images/visualizations/area.png new file mode 100644 index 0000000000000000000000000000000000000000..d5e594176bc1ed19f52afe779e8023ef9c1f4925 Binary files /dev/null and b/docs/users-guide/images/visualizations/area.png differ diff --git a/docs/users-guide/images/visualizations/bar.png b/docs/users-guide/images/visualizations/bar.png new file mode 100644 index 0000000000000000000000000000000000000000..e227ad1f7346906125db37be25e027774ebf4da1 Binary files /dev/null and b/docs/users-guide/images/visualizations/bar.png differ diff --git a/docs/users-guide/images/visualizations/donut.png b/docs/users-guide/images/visualizations/donut.png new file mode 100644 index 0000000000000000000000000000000000000000..1414e0e883030ca400e8f99e47518f55ab1e3cd9 Binary files /dev/null and b/docs/users-guide/images/visualizations/donut.png differ diff --git a/docs/users-guide/images/visualizations/funnel.png b/docs/users-guide/images/visualizations/funnel.png new file mode 100644 index 0000000000000000000000000000000000000000..9bd236fc892b4252fca1b8db0ccc09e5bc218748 Binary files /dev/null and b/docs/users-guide/images/visualizations/funnel.png differ diff --git a/docs/users-guide/images/visualizations/map.png b/docs/users-guide/images/visualizations/map.png new file mode 100644 index 0000000000000000000000000000000000000000..bf6616aa9fbe28854a99f8f31783cfa66eb95db2 Binary files /dev/null and b/docs/users-guide/images/visualizations/map.png differ diff --git a/docs/users-guide/images/visualizations/number.png b/docs/users-guide/images/visualizations/number.png new file mode 100644 index 0000000000000000000000000000000000000000..8c47b599c651c73013d1de00b0055abcddfa7743 Binary files /dev/null and b/docs/users-guide/images/visualizations/number.png differ diff --git a/docs/users-guide/images/visualizations/pin-map.png b/docs/users-guide/images/visualizations/pin-map.png new file mode 100644 index 0000000000000000000000000000000000000000..dd9e91a8b920c0d6a92efa32447158593ffe6cfa Binary files /dev/null and b/docs/users-guide/images/visualizations/pin-map.png differ diff --git a/docs/users-guide/images/visualizations/pivot.png b/docs/users-guide/images/visualizations/pivot.png new file mode 100644 index 0000000000000000000000000000000000000000..5cfdf14f6ff0a61b53e6ecb1a25a08ca36d3a7aa Binary files /dev/null and b/docs/users-guide/images/visualizations/pivot.png differ diff --git a/docs/users-guide/images/visualizations/progress.png b/docs/users-guide/images/visualizations/progress.png new file mode 100644 index 0000000000000000000000000000000000000000..9814e3356e6517e688c86cc12bfc1b972cb295dd Binary files /dev/null and b/docs/users-guide/images/visualizations/progress.png differ diff --git a/docs/users-guide/images/visualizations/scatter.png b/docs/users-guide/images/visualizations/scatter.png new file mode 100644 index 0000000000000000000000000000000000000000..b9324c5a56ce80d0dc4e6615c7eb2707f39f9446 Binary files /dev/null and b/docs/users-guide/images/visualizations/scatter.png differ diff --git a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx index a90673af6d57c8a2b610565923709c832551abda..ea5abefc8c5f745bece440f429f9836f0d7dfd12 100644 --- a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx +++ b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx @@ -4,17 +4,11 @@ import GuiQueryEditor from "metabase/query_builder/components/GuiQueryEditor.jsx import * as Urls from "metabase/lib/urls"; -import _ from "underscore"; import cx from "classnames"; -export default class PartialQueryBuilder extends Component { - constructor(props, context) { - super(props, context); - this.state = {}; - - _.bindAll(this, "setQuery"); - } +import * as Query from "metabase/lib/query/query"; +export default class PartialQueryBuilder extends Component { static propTypes = { onChange: PropTypes.func.isRequired, tableMetadata: PropTypes.object.isRequired, @@ -34,15 +28,15 @@ export default class PartialQueryBuilder extends Component { }); } - setQuery(query) { - this.props.onChange(query.query); - this.props.updatePreviewSummary(query); + setDatasetQuery = (datasetQuery) => { + this.props.onChange(datasetQuery.query); + this.props.updatePreviewSummary(datasetQuery); } render() { let { features, value, tableMetadata, previewSummary } = this.props; - let dataset_query = { + let datasetQuery = { type: "query", database: tableMetadata.db_id, query: { @@ -53,28 +47,39 @@ export default class PartialQueryBuilder extends Component { let previewCard = { dataset_query: { - ...dataset_query, + ...datasetQuery, query: { aggregation: ["rows"], breakout: [], filter: [], - ...dataset_query.query + ...datasetQuery.query } } }; let previewUrl = Urls.question(null, previewCard); + const onChange = (query) => { + this.props.onChange(query); + this.props.updatePreviewSummary({ ...datasetQuery, query }); + } + return ( <div className="py1"> <GuiQueryEditor - query={dataset_query} features={features} + datasetQuery={datasetQuery} tableMetadata={tableMetadata} databases={tableMetadata && [tableMetadata.db]} - setQueryFn={this.setQuery} + setDatasetQuery={this.setDatasetQuery} isShowingDataReference={false} setDatabaseFn={null} setSourceTableFn={null} + addQueryFilter={(filter) => onChange(Query.addFilter(datasetQuery.query, filter))} + updateQueryFilter={(index, filter) => onChange(Query.updateFilter(datasetQuery.query, index, filter))} + removeQueryFilter={(index) => onChange(Query.removeFilter(datasetQuery.query, index))} + addQueryAggregation={(aggregation) => onChange(Query.addAggregation(datasetQuery.query, aggregation))} + updateQueryAggregation={(index, aggregation) => onChange(Query.updateAggregation(datasetQuery.query, index, aggregation))} + removeQueryAggregation={(index) => onChange(Query.removeAggregation(datasetQuery.query, index))} > <div className="flex align-center mx2 my2"> <span className="text-bold px3">{previewSummary}</span> diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx index f3ffc2c8e365b2c4d2ee8fc685c3989a19ec1487..5ad0b08922d20b80bc018b316d59c9e614e54d6e 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx @@ -42,12 +42,12 @@ import cx from "classnames"; }, metricFormSelectors) export default class MetricForm extends Component { - updatePreviewSummary(query) { + updatePreviewSummary(datasetQuery) { this.props.updatePreviewSummary({ - ...query, + ...datasetQuery, query: { aggregation: ["count"], - ...query.query, + ...datasetQuery.query, } }) } diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx index f6b10dc6fdd91a127eb314e220f77c417750ab3c..cd99d32ff2475fa2f0fc75353fed4ee827892ea7 100644 --- a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx @@ -40,11 +40,11 @@ import cx from "classnames"; }, segmentFormSelectors) export default class SegmentForm extends Component { - updatePreviewSummary(query) { + updatePreviewSummary(datasetQuery) { this.props.updatePreviewSummary({ - ...query, + ...datasetQuery, query: { - ...query.query, + ...datasetQuery.query, aggregation: ["count"] } }) diff --git a/frontend/src/metabase/components/ActionButton.jsx b/frontend/src/metabase/components/ActionButton.jsx index fd83b2b465d0ec4da87dc278a707d018ced8ea7c..465fd693c6b50dca5656caac3899d8587748f731 100644 --- a/frontend/src/metabase/components/ActionButton.jsx +++ b/frontend/src/metabase/components/ActionButton.jsx @@ -17,6 +17,7 @@ type Props = { activeText?: string, failedText?: string, successText?: string, + forceActiveStyle?: boolean } type State = { @@ -48,7 +49,8 @@ export default class ActionButton extends Component<*, Props, State> { normalText: "Save", activeText: "Saving...", failedText: "Save failed", - successText: "Saved" + successText: "Saved", + forceActiveStyle: false }; componentWillUnmount() { @@ -96,13 +98,13 @@ export default class ActionButton extends Component<*, Props, State> { render() { // eslint-disable-next-line no-unused-vars - const { normalText, activeText, failedText, successText, actionFn, className, children, ...props } = this.props; + const { normalText, activeText, failedText, successText, actionFn, className, forceActiveStyle, children, ...props } = this.props; const { active, result } = this.state; return ( <Button {...props} - className={cx(className, { + className={forceActiveStyle ? cx('Button', 'Button--waiting') : cx(className, { 'Button--waiting pointer-events-none': active, 'Button--success': result === 'success', 'Button--danger': result === 'failed' @@ -114,7 +116,7 @@ export default class ActionButton extends Component<*, Props, State> { activeText : result === "success" ? <span> - <Icon name='check' size={12} /> + {forceActiveStyle ? null : <Icon name='check' size={12} /> } <span className="ml1">{successText}</span> </span> : result === "failed" ? diff --git a/frontend/src/metabase/components/DownloadButton.jsx b/frontend/src/metabase/components/DownloadButton.jsx index 003f86365b2dfb90d8328cd7ea40078ee118f94a..2095f254563529a1ecf8d76f3a6b300885a7edc1 100644 --- a/frontend/src/metabase/components/DownloadButton.jsx +++ b/frontend/src/metabase/components/DownloadButton.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import Button from "metabase/components/Button.jsx"; diff --git a/frontend/src/metabase/components/Select.jsx b/frontend/src/metabase/components/Select.jsx index 5fb23f9a3465f07cca051609c0ce5a0b735016b4..20adc9c358a24b0216b040e7acf630be9878d727 100644 --- a/frontend/src/metabase/components/Select.jsx +++ b/frontend/src/metabase/components/Select.jsx @@ -79,13 +79,8 @@ class BrowserSelect extends Component { <PopoverWithTrigger ref="popover" className={className} - triggerElement={ - <div className={"flex align-center " + (!value ? " text-grey-3" : "")}> - <span className="AdminSelect-content mr1">{selectedName}</span> - <Icon className="AdminSelect-chevron flex-align-right" name="chevrondown" size={12} /> - </div> - } - triggerClasses={cx("AdminSelect", className)} + triggerElement={<SelectButton hasValue={!!value}>{selectedName}</SelectButton>} + triggerClasses={className} verticalAttachments={["top"]} isInitiallyOpen={isInitiallyOpen} > @@ -117,6 +112,17 @@ class BrowserSelect extends Component { } } +export const SelectButton = ({ hasValue, children }) => + <div className={"AdminSelect flex align-center " + (!hasValue ? " text-grey-3" : "")}> + <span className="AdminSelect-content mr1">{children}</span> + <Icon className="AdminSelect-chevron flex-align-right" name="chevrondown" size={12} /> + </div> + +SelectButton.propTypes = { + hasValue: PropTypes.bool, + children: PropTypes.any, +}; + export class Option extends Component { static propTypes = { children: PropTypes.any, diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index 6ff8eddaf882ca5377d7bd913ae43ade6c202c01..931f3d9969ea0b7ef2366c8923f40bc3c2876481 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import DashboardHeader from "../components/DashboardHeader.jsx"; import DashboardGrid from "../components/DashboardGrid.jsx"; diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index a8fda47120ea8019c567dd8b2b05bfeda9dabb89..a037198fc1b42e9a037b36d1dabe345f93f1cebe 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import GridLayout from "./grid/GridLayout.jsx"; import DashCard from "./DashCard.jsx"; diff --git a/frontend/src/metabase/dashboard/components/parameters/ParameterValueWidget.jsx b/frontend/src/metabase/dashboard/components/parameters/ParameterValueWidget.jsx index 4dc174858e4f88922e824912454125baaa5a0b83..4aacaf8dff48740d3752a72e4df363ff07088f3f 100644 --- a/frontend/src/metabase/dashboard/components/parameters/ParameterValueWidget.jsx +++ b/frontend/src/metabase/dashboard/components/parameters/ParameterValueWidget.jsx @@ -9,6 +9,7 @@ import DateRangeWidget from "./widgets/DateRangeWidget.jsx"; import DateRelativeWidget from "./widgets/DateRelativeWidget.jsx"; import DateMonthYearWidget from "./widgets/DateMonthYearWidget.jsx"; import DateQuarterYearWidget from "./widgets/DateQuarterYearWidget.jsx"; +import DateAllOptionsWidget from "./widgets/DateAllOptionsWidget.jsx"; import CategoryWidget from "./widgets/CategoryWidget.jsx"; import TextWidget from "./widgets/TextWidget.jsx"; @@ -22,6 +23,7 @@ const WIDGETS = { "date/relative": DateRelativeWidget, "date/month-year": DateMonthYearWidget, "date/quarter-year": DateQuarterYearWidget, + "date/all-options": DateAllOptionsWidget } export default class ParameterValueWidget extends Component { diff --git a/frontend/src/metabase/dashboard/components/parameters/ParametersPopover.jsx b/frontend/src/metabase/dashboard/components/parameters/ParametersPopover.jsx index ff2e3734277debf58b5abd0a87050b98e17461ec..d1788e09a6685d0dd315d0ecf388d9198a4db1db 100644 --- a/frontend/src/metabase/dashboard/components/parameters/ParametersPopover.jsx +++ b/frontend/src/metabase/dashboard/components/parameters/ParametersPopover.jsx @@ -58,7 +58,7 @@ const ParameterOptionsSectionsPane = ({ sections, onSelectSection }) => const ParameterOptionItem = ({ option, onClick }) => <li onClick={onClick} className="p1 px2 cursor-pointer brand-hover"> - <div className="text-brand text-bold">{option.name}</div> + <div className="text-brand text-bold">{option.menuName || option.name}</div> <div>{option.description}</div> </li> diff --git a/frontend/src/metabase/dashboard/components/parameters/widgets/DateAllOptionsWidget.jsx b/frontend/src/metabase/dashboard/components/parameters/widgets/DateAllOptionsWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8c6bf8188c4d73492833bf63dbbc0eb6db3a8189 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/parameters/widgets/DateAllOptionsWidget.jsx @@ -0,0 +1,148 @@ +/* @flow */ + +import React, {Component, PropTypes} from "react"; +import cx from "classnames"; + +import DatePicker, {DATE_OPERATORS} from "metabase/query_builder/components/filters/pickers/DatePicker.jsx"; +import {generateTimeFilterValuesDescriptions} from "metabase/lib/query_time"; + +import type {OperatorName} from "metabase/query_builder/components/filters/pickers/DatePicker.jsx"; +import type {FieldFilter, LocalFieldReference} from "metabase/meta/types/Query"; + +type UrlEncoded = string; +// $FlowFixMe +type RegexMatches = [string]; +type Deserializer = (RegexMatches) => FieldFilter; + +// Use a placeholder value as field references are not used in dashboard filters +// $FlowFixMe +const noopRef: LocalFieldReference = null; + +function getFilterValueSerializer(func: ((val1: string, val2: string) => UrlEncoded)) { + // $FlowFixMe + return filter => func(filter[2], filter[3]) +} + +const serializersByOperatorName: { [id: OperatorName]: (FieldFilter) => UrlEncoded } = { + // $FlowFixMe + "Previous": getFilterValueSerializer((value, unit) => `past${-value}${unit}s`), + "Next": getFilterValueSerializer((value, unit) => `next${value}${unit}s`), + "Current": getFilterValueSerializer((_, unit) => `this${unit}`), + "Before": getFilterValueSerializer((value) => `~${value}`), + "After": getFilterValueSerializer((value) => `${value}~`), + "On": getFilterValueSerializer((value) => `${value}`), + "Between": getFilterValueSerializer((from, to) => `${from}~${to}`) +}; + +function getFilterOperator(filter) { + return DATE_OPERATORS.find((op) => op.test(filter)); +} +function filterToUrlEncoded(filter: FieldFilter): ?UrlEncoded { + const operator = getFilterOperator(filter) + + if (operator) { + return serializersByOperatorName[operator.name](filter); + } else { + return null; + } +} + +const deserializersWithTestRegex: [{ testRegex: RegExp, deserialize: Deserializer}] = [ + {testRegex: /^past([0-9]+)([a-z]+)s$/, deserialize: (matches) => { + return ["time-interval", noopRef, -parseInt(matches[0]), matches[1]] + }}, + {testRegex: /^next([0-9]+)([a-z]+)s$/, deserialize: (matches) => { + return ["time-interval", noopRef, parseInt(matches[0]), matches[1]] + }}, + {testRegex: /^this([a-z]+)$/, deserialize: (matches) => ["time-interval", noopRef, "current", matches[0]] }, + {testRegex: /^~([0-9-T:]+)$/, deserialize: (matches) => ["<", noopRef, matches[0]]}, + {testRegex: /^([0-9-T:]+)~$/, deserialize: (matches) => [">", noopRef, matches[0]]}, + {testRegex: /^([0-9-T:]+)$/, deserialize: (matches) => ["=", noopRef, matches[0]]}, + // TODO 3/27/17 Atte Keinänen + // Unify BETWEEN -> between, IS_NULL -> is-null, NOT_NULL -> not-null throughout the codebase + // $FlowFixMe + {testRegex: /^([0-9-T:]+)~([0-9-T:]+)$/, deserialize: (matches) => ["BETWEEN", noopRef, matches[0], matches[1]]}, +]; + +function urlEncodedToFilter(urlEncoded: UrlEncoded): ?FieldFilter { + const deserializer = + deserializersWithTestRegex.find((des) => urlEncoded.search(des.testRegex) !== -1); + + if (deserializer) { + const substringMatches = deserializer.testRegex.exec(urlEncoded).splice(1); + return deserializer.deserialize(substringMatches); + } else { + return null; + } +} + +const prefixedOperators: [OperatorName] = ["Before", "After", "On", "Is Empty", "Not Empty"]; +function getFilterTitle(filter) { + const desc = generateTimeFilterValuesDescriptions(filter).join(" - ") + const op = getFilterOperator(filter); + const prefix = op && prefixedOperators.indexOf(op.name) !== -1 ? `${op.name} ` : ""; + return prefix + desc; +} + +type Props = { + setValue: (value: ?string) => void, + onClose: () => void +}; + +type State = { filter: FieldFilter }; + +export default class DateAllOptionsWidget extends Component<*, Props, State> { + state: State; + + constructor(props: Props) { + super(props); + + this.state = { + // $FlowFixMe + filter: props.value != null ? urlEncodedToFilter(props.value) || [] : [] + } + } + + static propTypes = {}; + static defaultProps = {}; + + static format = (urlEncoded: ?string) => { + if (urlEncoded == null) return null; + const filter = urlEncodedToFilter(urlEncoded); + + return filter ? getFilterTitle(filter) : null; + }; + + commitAndClose = () => { + this.props.setValue(filterToUrlEncoded(this.state.filter)); + this.props.onClose() + } + + setFilter = (filter: FieldFilter) => { + this.setState({filter}); + } + + isValid() { + const filterValues = this.state.filter.slice(2); + return filterValues.every((value) => value != null); + } + + render() { + return (<div style={{minWidth: "300px"}}> + <DatePicker + filter={this.state.filter} + onFilterChange={this.setFilter} + hideEmptinessOperators + hideTimeSelectors + /> + <div className="FilterPopover-footer p1"> + <button + className={cx("Button Button--purple full", {"disabled": !this.isValid()})} + onClick={this.commitAndClose} + > + Update filter + </button> + </div> + </div>) + } +} diff --git a/frontend/src/metabase/dashboard/containers/ParameterWidget.css b/frontend/src/metabase/dashboard/containers/ParameterWidget.css index fd52f835c96d35d0f5cd943b22a8fbc9180792b0..d7704049805f47a1c9f82cc979a71d665dbdd6fe 100644 --- a/frontend/src/metabase/dashboard/containers/ParameterWidget.css +++ b/frontend/src/metabase/dashboard/containers/ParameterWidget.css @@ -12,7 +12,8 @@ position: relative; height: 0; line-height: 0; - margin-left: 0.5em; + margin-left: -0.45em; + padding: 0 0.5em; } :local(.container.deemphasized) { diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index e1c4bf1a7780d6e9b84d4ca8870fb3212c3b4675..a4c0902142b699967314a7f7bd6e42040f8f51b0 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -42,6 +42,9 @@ export var ICON_PATHS = { path: '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', attrs: { scale: 2 } }, + compass_needle: { + path: 'M0 32l10.706-21.064L32 0 21.22 20.89 0 32zm16.092-12.945a3.013 3.013 0 0 0 3.017-3.009 3.013 3.013 0 0 0-3.017-3.008 3.013 3.013 0 0 0-3.017 3.008 3.013 3.013 0 0 0 3.017 3.009z' + }, connections: { path: 'M5.37815706,11.5570815 C5.55061975,11.1918363 5.64705882,10.783651 5.64705882,10.3529412 C5.64705882,9.93118218 5.55458641,9.53102128 5.38881053,9.1716274 L11.1846365,4.82475792 C11.6952189,5.33295842 12.3991637,5.64705882 13.1764706,5.64705882 C14.7358628,5.64705882 16,4.38292165 16,2.82352941 C16,1.26413718 14.7358628,0 13.1764706,0 C11.6170784,0 10.3529412,1.26413718 10.3529412,2.82352941 C10.3529412,3.2452884 10.4454136,3.64544931 10.6111895,4.00484319 L10.6111895,4.00484319 L4.81536351,8.35171266 C4.3047811,7.84351217 3.60083629,7.52941176 2.82352941,7.52941176 C1.26413718,7.52941176 0,8.79354894 0,10.3529412 C0,11.9123334 1.26413718,13.1764706 2.82352941,13.1764706 C3.59147157,13.1764706 4.28780867,12.8698929 4.79682555,12.3724528 L10.510616,16.0085013 C10.408473,16.3004758 10.3529412,16.6143411 10.3529412,16.9411765 C10.3529412,18.5005687 11.6170784,19.7647059 13.1764706,19.7647059 C14.7358628,19.7647059 16,18.5005687 16,16.9411765 C16,15.3817842 14.7358628,14.1176471 13.1764706,14.1176471 C12.3029783,14.1176471 11.5221273,14.5142917 11.0042049,15.1372938 L5.37815706,11.5570815 Z', attrs: { viewBox: '0 0 16 19.7647' } diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index 78129ab3acb78548f9725dc2a7ccaf1f91c90aa7..2b0ac9090b755dc86b2a981f6fdd1cb00de4faa3 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -65,11 +65,11 @@ export function isCardRunnable(card, tableMetadata) { if (!card) { return false; } - const query = card.dataset_query; - if (query.query) { - return Query.canRun(query.query, tableMetadata); + const datasetQuery = card.dataset_query; + if (datasetQuery.query) { + return Query.canRun(datasetQuery.query, tableMetadata); } else { - return (query.database != undefined && query.native.query !== ""); + return (datasetQuery.database != undefined && datasetQuery.native.query !== ""); } } diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js index e4a5fb4c2fafe398c8276c981bcd5e85c07d5a61..55895e556c7dab165b039eb01faf75280db95a4e 100644 --- a/frontend/src/metabase/lib/data_grid.js +++ b/frontend/src/metabase/lib/data_grid.js @@ -37,16 +37,19 @@ export function pivot(data) { normalColValues.sort(); } - // make sure that the first element in the pivoted column list is null which makes room for the label of the other column pivotColValues.unshift(data.cols[normalCol].display_name); // start with an empty grid that we'll fill with the appropriate values - var pivotedRows = []; - var emptyRow = Array.apply(null, Array(pivotColValues.length)).map(function() { return null; }); - for (var i=0; i < normalColValues.length; i++) { - pivotedRows.push(_.clone(emptyRow)); - } + const pivotedRows = normalColValues.map((normalColValues, index) => { + const row = pivotColValues.map(() => null); + // for onVisualizationClick: + row._dimension = { + value: normalColValues, + column: data.cols[normalCol] + }; + return row; + }) // fill it up with the data for (var j=0; j < data.rows.length; j++) { @@ -59,14 +62,20 @@ export function pivot(data) { } // provide some column metadata to maintain consistency - var cols = pivotColValues.map(function(val, idx) { + const cols = pivotColValues.map(function(value, idx) { if (idx === 0) { // first column is always the coldef of the normal column return data.cols[normalCol]; } var colDef = _.clone(data.cols[cellCol]); - colDef['name'] = colDef['display_name'] = formatValue(val, { column: data.cols[pivotCol] }) || ""; + colDef.name = colDef.display_name = formatValue(value, { column: data.cols[pivotCol] }) || ""; + // for onVisualizationClick: + colDef._dimension = { + value: value, + column: data.cols[pivotCol] + }; + // delete colDef.id return colDef; }); diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index 1c1f0c27e922bdfdeb5a20c158d1f90e010f645a..26a11b0385b94faf4066358028102d45da770b62 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -178,7 +178,7 @@ var STYLE_SHEET = (function() { return style.sheet; })(); -export function addCSSRule(selector, rules, index) { +export function addCSSRule(selector, rules, index = 0) { if("insertRule" in STYLE_SHEET) { STYLE_SHEET.insertRule(selector + "{" + rules + "}", index); } diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index e80aa5f8abfc576b49e50bcf97aa761381421c9f..1a2a30921af916035253fe3af8f0f1092a0d90f7 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -64,7 +64,7 @@ function formatMajorMinor(major, minor, options = {}) { } } -function formatTimeWithUnit(value, unit, options = {}) { +export function formatTimeWithUnit(value, unit, options = {}) { let m = parseTimestamp(value, unit); if (!m.isValid()) { return String(value); diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index 9e9dfbddb224929f73d164b7488bed9c475420c7..e8d0360e122e7eb09de383ee364d07adbacd14b5 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -10,6 +10,7 @@ import { isFK, TYPE } from "metabase/lib/types"; import { stripId } from "metabase/lib/formatting"; import { format as formatExpression } from "metabase/lib/expressions/formatter"; +import * as Table from "./query/table"; import * as Q from "./query/query"; import { mbql, mbqlEq } from "./query/util"; @@ -270,7 +271,7 @@ var Query = { }, getExpressions(query) { - return query.expressions; + return query.expressions || {}; }, setExpression(query, name, expression) { @@ -744,18 +745,6 @@ export const BreakoutClause = { } } -const Table = { - getField(table, fieldId) { - if (table) { - // sometimes we populate fields_lookup, sometimes we don't :( - if (table.fields_lookup) { - return table.fields_lookup[fieldId]; - } else { - return _.findWhere(table.fields, { id: fieldId }); - } - } - } -} function joinList(list, joiner) { return _.flatten(list.map((l, i) => i === list.length - 1 ? [l] : [l, joiner]), true); diff --git a/frontend/src/metabase/lib/query/breakout.js b/frontend/src/metabase/lib/query/breakout.js index 8e7954e227b37974f0d45e8d077c4553f7f602ed..5e1f0f9b6961fa8cf43ea1f5266572217868e8d2 100644 --- a/frontend/src/metabase/lib/query/breakout.js +++ b/frontend/src/metabase/lib/query/breakout.js @@ -1,12 +1,20 @@ /* @flow */ import type { Breakout, BreakoutClause } from "metabase/meta/types/Query"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; +import type { Field } from "metabase/meta/types/Field"; + +import Q from "metabase/lib/query"; import { add, update, remove, clear } from "./util"; // returns canonical list of Breakouts, with nulls removed -export function getBreakouts(breakout: ?BreakoutClause): Breakout[] { - return (breakout || []).filter(b => b != null); +export function getBreakouts(breakouts: ?BreakoutClause): Breakout[] { + return (breakouts || []).filter(b => b != null); +} + +export function getBreakoutFields(breakouts: ?BreakoutClause, tableMetadata: TableMetadata): Field[] { + return getBreakouts(breakouts).map(breakout => (Q.getFieldTarget(breakout, tableMetadata) || {}).field); } // turns a list of Breakouts into the canonical BreakoutClause diff --git a/frontend/src/metabase/lib/query/expression.js b/frontend/src/metabase/lib/query/expression.js new file mode 100644 index 0000000000000000000000000000000000000000..e8612329c618ed9d05e84ee003dfa949edbed196 --- /dev/null +++ b/frontend/src/metabase/lib/query/expression.js @@ -0,0 +1,27 @@ +import _ from "underscore"; + +import type { ExpressionName, ExpressionClause, Expression } from "metabase/meta/types/Query"; + +export function getExpressions(expressions: ?ExpressionClause = {}): ExpressionClause { + return expressions; +} + +export function getExpressionsList(expressions: ?ExpressionClause = {}): Array<{ name: ExpressionName, expression: Expression }> { + return Object.entries(expressions).map(([name, expression]) => ({ name, expression })); +} + +export function addExpression(expressions: ?ExpressionClause = {}, name: ExpressionName, expression: Expression): ?ExpressionClause { + return { ...expressions, [name]: expression }; +} +export function updateExpression(expressions: ?ExpressionClause = {}, name: ExpressionName, expression: Expression, oldName?: ExpressionName): ?ExpressionClause { + if (oldName != null) { + expressions = removeExpression(expressions, oldName); + } + return addExpression(expressions, name, expression); +} +export function removeExpression(expressions: ?ExpressionClause = {}, name: ExpressionName): ?ExpressionClause { + return _.omit(expressions, name) +} +export function clearExpressions(expressions: ?ExpressionClause): ?ExpressionClause { + return {}; +} diff --git a/frontend/src/metabase/lib/query/filter.js b/frontend/src/metabase/lib/query/filter.js index c36a1629d064b1074eedf2cf2877c424910a308f..8f3851141ecba3fae9eb97e66791a5ccc4e39c48 100644 --- a/frontend/src/metabase/lib/query/filter.js +++ b/frontend/src/metabase/lib/query/filter.js @@ -48,3 +48,15 @@ export function canAddFilter(filter: ?FilterClause): boolean { } return true; } + +export function isSegmentFilter(filter: FilterClause): boolean { + return Array.isArray(filter) && mbqlEq(filter[0], "segment"); +} + +export function isCompoundFilter(filter: FilterClause): boolean { + return Array.isArray(filter) && (mbqlEq(filter[0], "and") || mbqlEq(filter[0], "or")); +} + +export function isFieldFilter(filter: FilterClause): boolean { + return !isSegmentFilter(filter) && !isCompoundFilter(filter); +} diff --git a/frontend/src/metabase/lib/query/query.js b/frontend/src/metabase/lib/query/query.js index de1973c40c5c55d7fa5196d1eb6495a0b2a692c4..56a7501001eea2fa9f60feffc7ecde7bc489f6e3 100644 --- a/frontend/src/metabase/lib/query/query.js +++ b/frontend/src/metabase/lib/query/query.js @@ -6,14 +6,17 @@ import type { Breakout, BreakoutClause, Filter, FilterClause, LimitClause, - OrderBy, OrderByClause + OrderBy, OrderByClause, + ExpressionClause, ExpressionName, Expression } from "metabase/meta/types/Query"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; import * as A from "./aggregation"; import * as B from "./breakout"; import * as F from "./filter"; import * as L from "./limit"; import * as O from "./order_by"; +import * as E from "./expression"; import Query from "metabase/lib/query"; import _ from "underscore"; @@ -38,6 +41,8 @@ export const updateBreakout = (query: SQ, index: number, breakout: Breakout) => export const removeBreakout = (query: SQ, index: number) => setBreakoutClause(query, B.removeBreakout(query.breakout, index)); export const clearBreakouts = (query: SQ) => setBreakoutClause(query, B.clearBreakouts(query.breakout)); +export const getBreakoutFields = (query: SQ, tableMetadata: TableMetadata) => B.getBreakoutFields(query.breakout, tableMetadata); + // FILTER export const getFilters = (query: SQ) => F.getFilters(query.filter); @@ -61,6 +66,19 @@ export const clearOrderBy = (query: SQ) => se export const updateLimit = (query: SQ, limit: LimitClause) => setLimitClause(query, L.updateLimit(query.limit, limit)); export const clearLimit = (query: SQ) => setLimitClause(query, L.clearLimit(query.limit)); +// EXPRESSIONS + +export const getExpressions = (query: SQ) => E.getExpressions(query.expressions); +export const getExpressionsList = (query: SQ) => E.getExpressionsList(query.expressions); +export const addExpression = (query: SQ, name: ExpressionName, expression: Expression) => + setExpressionClause(query, E.addExpression(query.expressions, name, expression)); +export const updateExpression = (query: SQ, name: ExpressionName, expression: Expression, oldName: ExpressionName) => + setExpressionClause(query, E.updateExpression(query.expressions, name, expression, oldName)); +export const removeExpression = (query: SQ, name: ExpressionName) => + setExpressionClause(query, E.removeExpression(query.expressions, name)); +export const clearExpression = (query: SQ) => + setExpressionClause(query, E.clearExpressions(query.expressions)); + // we can enforce various constraints in these functions: function setAggregationClause(query: SQ, aggregationClause: ?AggregationClause): SQ { @@ -95,9 +113,15 @@ function setOrderByClause(query: SQ, orderByClause: ?OrderByClause): SQ { function setLimitClause(query: SQ, limitClause: ?LimitClause): SQ { return setClause("limit", query, limitClause); } +function setExpressionClause(query: SQ, expressionClause: ?ExpressionClause): SQ { + if (expressionClause && Object.keys(expressionClause).length === 0) { + expressionClause = null; + } + return setClause("expressions", query, expressionClause); +} // TODO: remove mutation -type FilterClauseName = "filter"|"aggregation"|"breakout"|"order_by"|"limit"; +type FilterClauseName = "filter"|"aggregation"|"breakout"|"order_by"|"limit"|"expressions"; function setClause(clauseName: FilterClauseName, query: SQ, clause: ?any): SQ { if (clause == null) { delete query[clauseName]; diff --git a/frontend/src/metabase/lib/query/table.js b/frontend/src/metabase/lib/query/table.js new file mode 100644 index 0000000000000000000000000000000000000000..96d9786e175aca8c45a0c442a5a0f2de71b2d73b --- /dev/null +++ b/frontend/src/metabase/lib/query/table.js @@ -0,0 +1,14 @@ +/* @flow weak */ + +import _ from "underscore"; + +export function getField(table, fieldId) { + if (table) { + // sometimes we populate fields_lookup, sometimes we don't :( + if (table.fields_lookup) { + return table.fields_lookup[fieldId]; + } else { + return _.findWhere(table.fields, { id: fieldId }); + } + } +} diff --git a/frontend/src/metabase/lib/query_time.js b/frontend/src/metabase/lib/query_time.js index ac1b93f6dc403595fff1999c1345b6ed51110503..a57b5509c396c3b25c3aa6e7245201e5777a3d0e 100644 --- a/frontend/src/metabase/lib/query_time.js +++ b/frontend/src/metabase/lib/query_time.js @@ -2,6 +2,7 @@ import moment from "moment"; import inflection from "inflection"; import { mbqlEq } from "metabase/lib/query/util"; +import { formatTimeWithUnit } from "metabase/lib/formatting"; export function computeFilterTimeRange(filter) { let expandedFilter; @@ -110,7 +111,9 @@ export function generateTimeIntervalDescription(n, unit) { export function generateTimeValueDescription(value, bucketing) { if (typeof value === "string") { let m = moment(value); - if(m.hours() || m.minutes()) { + if (bucketing) { + return formatTimeWithUnit(value, bucketing); + } else if (m.hours() || m.minutes()) { return m.format("MMMM D, YYYY hh:mm a"); } else { return m.format("MMMM D, YYYY"); @@ -169,13 +172,22 @@ export function parseFieldBucketing(field, defaultUnit = null) { return defaultUnit; } +// returns field with "datetime-field" removed export function parseFieldTarget(field) { + if (mbqlEq(field[0], "datetime-field")) { + return field[1]; + } else { + return field; + } +} + +export function parseFieldTargetId(field) { if (Number.isInteger(field)) return field; if (Array.isArray(field)) { if (mbqlEq(field[0], "field-id")) return field[1]; if (mbqlEq(field[0], "fk->")) return field[1]; - if (mbqlEq(field[0], "datetime-field")) return parseFieldTarget(field[1]); + if (mbqlEq(field[0], "datetime-field")) return parseFieldTargetId(field[1]); } console.warn("Unknown field format", field); diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index dd593ad9aae7b778f773ce7f23f52683ec8084f7..9f2523301585ab8b60fbf059b699d7bcb69a7825 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -2,35 +2,12 @@ import moment from "moment"; import _ from "underscore"; import { getIn } from "icepick"; -import { createStore as originalCreateStore, applyMiddleware, compose } from "redux"; -import promise from 'redux-promise'; -import thunk from "redux-thunk"; -import createLogger from "redux-logger"; - -import createHistory from "history/createBrowserHistory"; - -import { reduxReactRouter } from 'redux-router'; - import { setRequestState, clearRequestState } from "metabase/redux/requests"; // convienence export { combineReducers } from "redux"; export { handleActions, createAction } from "redux-actions"; -import { DEBUG } from "metabase/lib/debug"; - -let middleware = [thunk, promise]; -if (DEBUG) { - middleware.push(createLogger()); -} - -// common createStore with middleware applied -export const createStore = compose( - applyMiddleware(...middleware), - reduxReactRouter({ createHistory }), - window.devToolsExtension ? window.devToolsExtension() : f => f -)(originalCreateStore); - // similar to createAction but accepts a (redux-thunk style) thunk and dispatches based on whether // the promise returned from the thunk resolves or rejects, similar to redux-promise export function createThunkAction(actionType, actionThunkCreator) { diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index af86137411499c5337cd324a9885e3809ab52611..ef30ddb624d8ab28c8eae82d1abcabcec8de4879 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -115,9 +115,12 @@ export const isNumericBaseType = (field) => isa(field && field.base_type, TYPE.N // ZipCode, ID, etc derive from Number but should not be formatted as numbers export const isNumber = (field) => field && isNumericBaseType(field) && (field.special_type == null || field.special_type === TYPE.Number); -export const isCoordinate = (field) => isa(field && field.special_type, TYPE.Coordinate); -export const isLatitude = (field) => isa(field && field.special_type, TYPE.Latitude); -export const isLongitude = (field) => isa(field && field.special_type, TYPE.Longitude); +export const isAddress = (field) => isa(field && field.special_type, TYPE.Address); +export const isState = (field) => isa(field && field.special_type, TYPE.State); +export const isCountry = (field) => isa(field && field.special_type, TYPE.Country); +export const isCoordinate = (field) => isa(field && field.special_type, TYPE.Coordinate); +export const isLatitude = (field) => isa(field && field.special_type, TYPE.Latitude); +export const isLongitude = (field) => isa(field && field.special_type, TYPE.Longitude); // operator argument constructors: diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index 632cf91c29fb812df8749ec6f505e2f399e51a8a..7c2eba73166aeef3535ac9f4ee9e9f3b995fba8d 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -1,7 +1,7 @@ -// provides functions for building urls to things we care about - import { serializeCardForUrl } from "metabase/lib/card"; +// provides functions for building urls to things we care about + export function question(cardId, cardOrHash = "") { if (cardOrHash && typeof cardOrHash === "object") { cardOrHash = serializeCardForUrl(cardOrHash); diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index 8c2316dffad9f7b50cbf00f2b23bc51d61a51e10..996ae2972b7e5ae3d108b6dbe593f85603f8a99d 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -43,8 +43,8 @@ export function isNative(card: Card): bool { export function canRun(card: Card): bool { if (card.dataset_query.type === "query") { - const query : StructuredQuery = card.dataset_query.query; - return query && query.source_table != undefined && Query.hasValidAggregation(query); + const query = getQuery(card); + return query != null && query.source_table != undefined && Query.hasValidAggregation(query); } else if (card.dataset_query.type === "native") { const native : NativeQuery = card.dataset_query.native; return native && card.dataset_query.database != undefined && native.query !== ""; @@ -90,29 +90,25 @@ export function applyParameters( datasetQuery.parameters = []; for (const parameter of parameters || []) { let value = parameterValues[parameter.id]; + if (value == null) { + continue; + } - // dashboards const mapping = _.findWhere(parameterMappings, { card_id: card.id, parameter_id: parameter.id }); - if (value != null && mapping) { + if (mapping) { + // mapped target, e.x. on a dashboard datasetQuery.parameters.push({ type: parameter.type, target: mapping.target, value: value }); - } - - // SQL parameters - if (datasetQuery.type === "native") { - let tag = _.findWhere(datasetQuery.native.template_tags, { id: parameter.id }); - if (tag) { - datasetQuery.parameters.push({ - type: parameter.type, - target: tag.type === "dimension" ? - ["dimension", ["template-tag", tag.name]]: - ["variable", ["template-tag", tag.name]], - value: value - }); - } + } else if (parameter.target) { + // inline target, e.x. on a card + datasetQuery.parameters.push({ + type: parameter.type, + target: parameter.target, + value: value + }); } } diff --git a/frontend/src/metabase/meta/Dashboard.js b/frontend/src/metabase/meta/Dashboard.js index 8d5d60f0bb5c5bd054ecbdc94f7894e926976769..b03eacc7052c3aa1a49af63dd9b710b8e15ed86b 100644 --- a/frontend/src/metabase/meta/Dashboard.js +++ b/frontend/src/metabase/meta/Dashboard.js @@ -6,7 +6,7 @@ import type Field from "./metadata/Field"; import type { FieldId } from "./types/Field"; import type { TemplateTag } from "./types/Query"; import type { Card } from "./types/Card"; -import type { ParameterOption, Parameter, ParameterMappingUIOption, ParameterMappingTarget, DimensionTarget, VariableTarget } from "./types/Dashboard"; +import type { ParameterOption, Parameter, ParameterType, ParameterMappingUIOption, ParameterMappingTarget, DimensionTarget, VariableTarget } from "./types/Dashboard"; import { getTemplateTags } from "./Card"; @@ -43,6 +43,12 @@ export const PARAMETER_OPTIONS: Array<ParameterOption> = [ name: "Relative Date", description: "Like \"the last 7 days\" or \"this month\"" }, + { + type: "date/all-options", + name: "Date Filter", + menuName: "All Options", + description: "Contains all of the above" + }, { type: "location/city", name: "City" @@ -217,14 +223,18 @@ export function getParameterMappingTargetField(metadata: Metadata, card: Card, t return null; } -function fieldFilterForParameter(parameter: Parameter): FieldFilter { - const [type] = parameter.type.split("/"); +function fieldFilterForParameter(parameter: Parameter) { + return fieldFilterForParameterType(parameter.type); +} + +export function fieldFilterForParameterType(parameterType: ParameterType): FieldFilter { + const [type] = parameterType.split("/"); switch (type) { case "date": return (field: Field) => field.isDate(); case "id": return (field: Field) => field.isID(); case "category": return (field: Field) => field.isCategory(); } - switch (parameter.type) { + switch (parameterType) { case "location/city": return (field: Field) => isa(field.special_type, TYPE.City); case "location/state": return (field: Field) => isa(field.special_type, TYPE.State); case "location/zip_code": return (field: Field) => isa(field.special_type, TYPE.ZipCode); @@ -233,6 +243,10 @@ function fieldFilterForParameter(parameter: Parameter): FieldFilter { return (field: Field) => false; } +export function parameterOptionsForField(field: Field): ParameterOption[] { + return PARAMETER_OPTIONS.filter(option => fieldFilterForParameterType(option.type)(field)); +} + function tagFilterForParameter(parameter: Parameter): TemplateTagFilter { const [type, subtype] = parameter.type.split("/"); switch (type) { diff --git a/frontend/src/metabase/meta/Parameter.js b/frontend/src/metabase/meta/Parameter.js index 54db7ae4629c8e7f5a20696a479a4e7d665d526b..130ac93e4bdde4ee476af69733ccfc4303a35025 100644 --- a/frontend/src/metabase/meta/Parameter.js +++ b/frontend/src/metabase/meta/Parameter.js @@ -7,12 +7,15 @@ export type ParameterValues = { [id: ParameterId]: string }; +// NOTE: this should mirror `template-tag-parameters` in src/metabase/api/embed.clj export function getTemplateTagParameters(tags: TemplateTag[]): Parameter[] { - return tags.filter(tag => tag.type != null && tag.type !== "dimension") + return tags.filter(tag => tag.type != null && (tag.widget_type || tag.type !== "dimension")) .map(tag => ({ id: tag.id, - type: tag.type === "date" ? "date/single" : "category", - target: ["variable", ["template-tag", tag.name]], + type: tag.widget_type || (tag.type === "date" ? "date/single" : "category"), + target: tag.type === "dimension" ? + ["dimension", ["template-tag", tag.name]]: + ["variable", ["template-tag", tag.name]], name: tag.display_name, slug: tag.name, default: tag.default diff --git a/frontend/src/metabase/meta/types/Collection.js b/frontend/src/metabase/meta/types/Collection.js new file mode 100644 index 0000000000000000000000000000000000000000..13095f04db1666fee39fcd8e7761753e73884ddb --- /dev/null +++ b/frontend/src/metabase/meta/types/Collection.js @@ -0,0 +1,9 @@ +/* @flow */ + +export type CollectionId = number; + +export type Collection = { + id: CollectionId, + name: string, + color: string, +} diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 4f8c9f95e4671f1a389bd451a63ccef66218058e..90bbe3f1ed40bf5d0e1212b37633ed38fd49ed57 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -11,7 +11,8 @@ export type Column = { name: ColumnName, display_name: string, base_type: string, - special_type: ?string + special_type: ?string, + source?: "fields"|"aggregation"|"breakout" }; export type ISO8601Times = string; diff --git a/frontend/src/metabase/meta/types/Metadata.js b/frontend/src/metabase/meta/types/Metadata.js index 58872755f33dea358d1472d83ea4d29b14f2b435..4fb281711ba440ff13d1b42fdb1b7dedc5204dac 100644 --- a/frontend/src/metabase/meta/types/Metadata.js +++ b/frontend/src/metabase/meta/types/Metadata.js @@ -35,7 +35,32 @@ export type FieldMetadata = Field & { operators_lookup: { [name: string]: Operator } } +export type AggregationOption = { + name: string, + short: string, + fields: Field[], + validFieldsFilter: (fields: Field[]) => Field[] +} + +export type BreakoutOptions = { + name: string, + short: string, + fields: Field[], + validFieldsFilter: (fields: Field[]) => Field[] +} + export type TableMetadata = Table & { segments: Segment[], - fields: FieldMetadata[] + fields: FieldMetadata[], + aggregation_options: AggregationOption[], + breakout_options: BreakoutOptions } + +export type FieldOptions = { + count: number, + fields: Field[], + fks: { + field: Field, + fields: Field[] + } +}; diff --git a/frontend/src/metabase/meta/types/Parameter.js b/frontend/src/metabase/meta/types/Parameter.js new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js index 6d3c52333323bacdc0b7eee9c6840d1db0b77e2b..8f4ab74316c2417866e6f4efea6298e093391a60 100644 --- a/frontend/src/metabase/meta/types/Query.js +++ b/frontend/src/metabase/meta/types/Query.js @@ -3,6 +3,7 @@ import type { TableId } from "./Table"; import type { FieldId } from "./Field"; import type { SegmentId } from "./Segment"; +import type { ParameterType } from "./Dashboard"; export type MetricId = number; @@ -27,6 +28,8 @@ export type TemplateTag = { display_name: string, type: string, dimension?: ["field-id", number], + widget_type?: ParameterType, + required?: boolean, default?: string, }; diff --git a/frontend/src/metabase/meta/types/Table.js b/frontend/src/metabase/meta/types/Table.js index 002ebe272f476e49235eb4c99d2100d6fbfbb56a..51ca40ef2b89d44d2ee8b1ce0bfd80b9e865682b 100644 --- a/frontend/src/metabase/meta/types/Table.js +++ b/frontend/src/metabase/meta/types/Table.js @@ -1,6 +1,7 @@ /* @flow */ import type { Field } from "./Field"; +import type { DatabaseId } from "./Database"; export type TableId = number; export type SchemaName = string; @@ -9,6 +10,8 @@ export type SchemaName = string; export type Table = { id: TableId, + db_id: DatabaseId, + name: string, display_name: string, schema?: SchemaName, diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js new file mode 100644 index 0000000000000000000000000000000000000000..0c4631b4373e46e91fb290aa6e7be81e843eb052 --- /dev/null +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -0,0 +1,85 @@ +/* @flow */ + +import type { DatasetData, Column } from "metabase/meta/types/Dataset"; +import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; + +export type ActionCreator = (props: ClickActionProps) => ?ClickAction + +export type QueryMode = { + name: string, + actions: ActionCreator[], + drills: ActionCreator[] +} + +export type HoverData = Array<{ key: string, value: any, col?: Column }>; + +export type HoverObject = { + index?: number, + axisIndex?: number, + data?: HoverData, + element?: ?HTMLElement, + event?: MouseEvent, +} + +export type DimensionValue = { + value: Value, + column: Column +}; + +export type ClickObject = { + value?: Value, + column: Column, + dimensions?: DimensionValue[], + event?: MouseEvent, + element?: HTMLElement, +} + +export type ClickAction = { + title: any, // React Element + icon?: string, + popover?: (props: ClickActionPopoverProps) => any, // React Element + card?: () => ?Card +} + +export type ClickActionProps = { + card: Card, + tableMetadata: TableMetadata, + clicked?: ClickObject +} + +export type ClickActionPopoverProps = { + onChangeCardAndRun: (card: ?Card) => void, + onClose: () => void, +} + +// type Visualization = Component<*, VisualizationProps, *>; + +// $FlowFixMe +export type Series = { card: Card, data: DatasetData }[] & { _raw: Series } + +export type VisualizationProps = { + series: Series, + card: Card, + data: DatasetData, + settings: VisualizationSettings, + + className?: string, + gridSize: ?{ + width: number, + height: number + }, + + showTitle: boolean, + isDashboard: boolean, + isEditing: boolean, + actionButtons: Node, + linkToCard?: bool, + + hovered: ?HoverObject, + onHoverChange: (?HoverObject) => void, + onVisualizationClick: (?ClickObject) => void, + visualizationIsClickable: (?ClickObject) => boolean, + + onUpdateVisualizationSettings: ({ [key: string]: any }) => void +} diff --git a/frontend/src/metabase/public/components/EmbedFrame.jsx b/frontend/src/metabase/public/components/EmbedFrame.jsx index d800987f0d574a7c898537e47878a1354d8105ac..5dcfb7f14379029b4a9c5d5de2bb3a15289bca96 100644 --- a/frontend/src/metabase/public/components/EmbedFrame.jsx +++ b/frontend/src/metabase/public/components/EmbedFrame.jsx @@ -1,7 +1,6 @@ /* @flow */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import { withRouter } from "react-router"; import { IFRAMED } from "metabase/lib/dom"; import Parameters from "metabase/dashboard/containers/Parameters"; diff --git a/frontend/src/metabase/pulse/actions.js b/frontend/src/metabase/pulse/actions.js index 35833cb196b8fe0dd00937519a904532656674e3..72d1270716297946f3c070539aa3dd5400d750a3 100644 --- a/frontend/src/metabase/pulse/actions.js +++ b/frontend/src/metabase/pulse/actions.js @@ -40,7 +40,8 @@ export const setEditingPulse = createThunkAction(SET_EDITING_PULSE, function(id) return { name: null, cards: [], - channels: [] + channels: [], + skip_if_empty: false, } }; }); diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx index a76852c8c9ac4283dde35fcceb94b8315f694c97..7d50c9bae6f5d01a32bb07201cf5dd07a071fc45 100644 --- a/frontend/src/metabase/pulse/components/PulseEdit.jsx +++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx @@ -5,6 +5,7 @@ import { Link } from "react-router"; import PulseEditName from "./PulseEditName.jsx"; import PulseEditCards from "./PulseEditCards.jsx"; import PulseEditChannels from "./PulseEditChannels.jsx"; +import PulseEditSkip from "./PulseEditSkip.jsx"; import WhatsAPulse from "./WhatsAPulse.jsx"; import ActionButton from "metabase/components/ActionButton.jsx"; @@ -116,6 +117,7 @@ export default class PulseEdit extends Component { <PulseEditName {...this.props} setPulse={this.setPulse} /> <PulseEditCards {...this.props} setPulse={this.setPulse} /> <PulseEditChannels {...this.props} setPulse={this.setPulse} pulseIsValid={isValid} /> + <PulseEditSkip {...this.props} setPulse={this.setPulse} /> { pulse && pulse.id != null && <div className="DangerZone mb2 p3 rounded bordered relative"> <h3 className="text-error absolute top bg-white px1" style={{ marginTop: "-12px" }}>Danger Zone</h3> diff --git a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx index da11f0d023368c0feb9d7fff10119ed096f8a04f..c92a3215312688b996aea6152064d99f87ea7cc5 100644 --- a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx +++ b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx @@ -38,7 +38,8 @@ export default class PulseEditChannels extends Component { user: PropTypes.object.isRequired, userList: PropTypes.array.isRequired, setPulse: PropTypes.func.isRequired, - testPulse: PropTypes.func.isRequired + testPulse: PropTypes.func.isRequired, + cardPreviews: PropTypes.array }; static defaultProps = {}; @@ -134,6 +135,15 @@ export default class PulseEditChannels extends Component { return this.props.testPulse({ ...this.props.pulse, channels: [channel] }); } + willPulseSkip = () => { + let cards = _.pluck(this.props.pulse.cards, 'id'); + let cardPreviews = this.props.cardPreviews; + let previews = _.map(cards, function (id) { return _.find(cardPreviews, function(card){ return (id == card.id);})}); + let types = _.pluck(previews, 'pulse_card_type'); + let empty = _.isEqual( _.uniq(types), ["empty"]); + return (empty && this.props.pulse.skip_if_empty); + } + renderFields(channel, index, channelSpec) { return ( <div> @@ -142,7 +152,7 @@ export default class PulseEditChannels extends Component { <span className="h4 text-bold mr1">{field.displayName}</span> { field.type === "select" ? <Select - className="h4 text-bold" + className="h4 text-bold bg-white" value={channel.details[field.name]} options={field.options} optionNameFn={o => o} @@ -194,7 +204,8 @@ export default class PulseEditChannels extends Component { "Send to " + channelSpec.name + " now"} activeText="Sending…" failedText="Sending failed" - successText="Pulse sent" + successText={ this.willPulseSkip() ? "Didn’t send because the pulse has no results." : "Pulse sent"} + forceActiveStyle={ this.willPulseSkip() } /> </div> </li> diff --git a/frontend/src/metabase/pulse/components/PulseEditSkip.jsx b/frontend/src/metabase/pulse/components/PulseEditSkip.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4c17054314ebac9831fd5b0dfc43c0f5a5b6ac10 --- /dev/null +++ b/frontend/src/metabase/pulse/components/PulseEditSkip.jsx @@ -0,0 +1,28 @@ +import React, { Component, PropTypes } from "react"; + +import Toggle from "metabase/components/Toggle.jsx"; + +export default class PulseEditSkip extends Component { + static propTypes = { + pulse: PropTypes.object.isRequired, + setPulse: PropTypes.func.isRequired, + }; + + toggle = () => { + const { pulse, setPulse } = this.props; + setPulse({ ...pulse, skip_if_empty: !pulse.skip_if_empty }); + } + + render() { + const { pulse } = this.props; + return ( + <div className="py1"> + <h2>Skip if no results</h2> + <p className="mt1 h4 text-bold text-grey-3">Skip a scheduled Pulse if none of its questions have any results.</p> + <div className="my3"> + <Toggle value={pulse.skip_if_empty || false} onChange={this.toggle} /> + </div> + </div> + ); + } +} diff --git a/frontend/src/metabase/pulse/components/RecipientPicker.jsx b/frontend/src/metabase/pulse/components/RecipientPicker.jsx index 3b69caf3a5aea5dfd6b7f6fe977278bbb5bec09e..67db8b2cd3cca1a4e5706a7d378f8ce417933624 100644 --- a/frontend/src/metabase/pulse/components/RecipientPicker.jsx +++ b/frontend/src/metabase/pulse/components/RecipientPicker.jsx @@ -154,7 +154,7 @@ export default class RecipientPicker extends Component { let { recipients } = this.props; return ( - <ul className={cx("px1 pb1 bordered rounded flex flex-wrap", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}> + <ul className={cx("px1 pb1 bordered rounded flex flex-wrap bg-white", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}> {recipients.map((recipient, index) => <li key={index} className="mr1 py1 pl1 mt1 rounded bg-grey-1"> <span className="h4 text-bold">{recipient.common_name || recipient.email}</span> @@ -163,12 +163,11 @@ export default class RecipientPicker extends Component { </a> </li> )} - <li className="flex-full mr1 py1 pl1 mt1" style={{ "minWidth": " 100px" }}> + <li className="flex-full mr1 py1 pl1 mt1 bg-white" style={{ "minWidth": " 100px" }}> <input ref="input" type="text" className="full h4 text-bold text-default no-focus borderless" - style={{"backgroundColor": "transparent"}} placeholder={recipients.length === 0 ? "Enter email addresses you'd like this data to go to" : null} value={this.state.inputValue} autoFocus={this.state.focused} diff --git a/frontend/src/metabase/pulse/components/SchedulePicker.jsx b/frontend/src/metabase/pulse/components/SchedulePicker.jsx index 7e1df516236f86ad69dcff9f451edb25a50d75c0..99e70181c37960d4ed5a2a5839b88653f4bcd024 100644 --- a/frontend/src/metabase/pulse/components/SchedulePicker.jsx +++ b/frontend/src/metabase/pulse/components/SchedulePicker.jsx @@ -56,6 +56,7 @@ export default class SchedulePicker extends Component { value={_.find(MONTH_DAY_OPTIONS, (o) => o.value === c.schedule_frame)} options={MONTH_DAY_OPTIONS} optionNameFn={o => o.name} + className="bg-white" optionValueFn={o => o.value} onChange={(o) => this.props.onPropertyChange("schedule_frame", o) } /> @@ -66,6 +67,7 @@ export default class SchedulePicker extends Component { options={DAY_OPTIONS} optionNameFn={o => o.name} optionValueFn={o => o.value} + className="bg-white" onChange={(o) => this.props.onPropertyChange("schedule_day", o) } /> </span> @@ -83,6 +85,7 @@ export default class SchedulePicker extends Component { options={DAY_OF_WEEK_OPTIONS} optionNameFn={o => o.name} optionValueFn={o => o.value} + className="bg-white" onChange={(o) => this.props.onPropertyChange("schedule_day", o) } /> </span> diff --git a/frontend/src/metabase/qb/.eslintrc b/frontend/src/metabase/qb/.eslintrc new file mode 100644 index 0000000000000000000000000000000000000000..b838b6ce387bf8a92740f01a96971b63b99d3eff --- /dev/null +++ b/frontend/src/metabase/qb/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "flowtype/require-valid-file-annotation": [2, "always"] + } +} diff --git a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bafa6394ae0c9402c97694f6ffd4e60f7c25a0f9 --- /dev/null +++ b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx @@ -0,0 +1,162 @@ +/* @flow weak */ + +import React, { Component, PropTypes } from "react"; + +import DatePicker + from "metabase/query_builder/components/filters/pickers/DatePicker"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; +import { SelectButton } from "metabase/components/Select"; +import Button from "metabase/components/Button"; + +import * as Query from "metabase/lib/query/query"; +import * as Filter from "metabase/lib/query/filter"; +import * as Field from "metabase/lib/query/field"; +import * as Card from "metabase/meta/Card"; + +import { + parseFieldTarget, + parseFieldTargetId, + generateTimeFilterValuesDescriptions +} from "metabase/lib/query_time"; + +import cx from "classnames"; +import _ from "underscore"; + +import type { + Card as CardObject, + DatasetQuery +} from "metabase/meta/types/Card"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; +import type { FieldFilter } from "metabase/meta/types/Query"; + +type Props = { + className: string, + card: CardObject, + tableMetadata: TableMetadata, + setDatasetQuery: (datasetQuery: DatasetQuery) => void, + runQueryFn: () => void +}; + +type State = { + filterIndex: number, + filter: FieldFilter, + currentFilter: any +}; + +export default class TimeseriesFilterWidget extends Component<*, Props, State> { + state = { + filter: null, + filterIndex: -1, + currentFilter: null + }; + + _popover: ?any; + + componentWillMount() { + this.componentWillReceiveProps(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + const query = Card.getQuery(nextProps.card); + if (query) { + const breakouts = Query.getBreakouts(query); + const filters = Query.getFilters(query); + + const timeFieldId = parseFieldTargetId(breakouts[0]); + const timeField = parseFieldTarget(breakouts[0]); + + const filterIndex = _.findIndex( + filters, + filter => + Filter.isFieldFilter(filter) && + Field.getFieldTargetId(filter[1]) === timeFieldId + ); + + let filter, currentFilter; + if (filterIndex >= 0) { + filter = (currentFilter = filters[filterIndex]); + } else { + filter = ["time-interval", timeField, -30, "day"]; + } + + // $FlowFixMe + this.setState({ filter, filterIndex, currentFilter }); + } + } + + render() { + const { + className, + card, + tableMetadata, + setDatasetQuery, + runQueryFn + } = this.props; + const { filter, filterIndex, currentFilter } = this.state; + let currentDescription; + + if (currentFilter) { + currentDescription = generateTimeFilterValuesDescriptions( + currentFilter + ).join(" - "); + if (currentFilter[0] === ">") { + currentDescription = "After " + currentDescription; + } else if (currentFilter[0] === "<") { + currentDescription = "Before " + currentDescription; + } + } else { + currentDescription = "All Time"; + } + + return ( + <PopoverWithTrigger + triggerElement={ + <SelectButton hasValue> + {currentDescription} + </SelectButton> + } + triggerClasses={cx(className, "my2")} + ref={ref => this._popover = ref} + sizeToFit + > + <DatePicker + className="mt2" + filter={this.state.filter} + onFilterChange={newFilter => { + this.setState({ filter: newFilter }); + }} + tableMetadata={tableMetadata} + /> + <div className="p1"> + <Button + purple + className="full" + onClick={() => { + let query = Card.getQuery(card); + if (query) { + if (filterIndex >= 0) { + query = Query.updateFilter( + query, + filterIndex, + filter + ); + } else { + query = Query.addFilter(query, filter); + } + // $FlowFixMe + setDatasetQuery({ + ...card.dataset_query, + query + }); + runQueryFn(); + } + if (this._popover) { + this._popover.close(); + } + }} + >Apply</Button> + </div> + </PopoverWithTrigger> + ); + } +} diff --git a/frontend/src/metabase/qb/components/TimeseriesGroupingWidget.jsx b/frontend/src/metabase/qb/components/TimeseriesGroupingWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4681fdbe7a139b647831b85cdeb18b9ec02bcb36 --- /dev/null +++ b/frontend/src/metabase/qb/components/TimeseriesGroupingWidget.jsx @@ -0,0 +1,88 @@ +/* @flow weak */ + +import React, { Component, PropTypes } from "react"; + +import TimeGroupingPopover + from "metabase/query_builder/components/TimeGroupingPopover"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; +import { SelectButton } from "metabase/components/Select"; + +import * as Query from "metabase/lib/query/query"; +import * as Card from "metabase/meta/Card"; + +import { parseFieldBucketing, formatBucketing } from "metabase/lib/query_time"; + +import type { + Card as CardObject, + DatasetQuery +} from "metabase/meta/types/Card"; + +type Props = { + card: CardObject, + setDatasetQuery: (datasetQuery: DatasetQuery) => void, + runQueryFn: () => void +}; + +export default class TimeseriesGroupingWidget extends Component<*, Props, *> { + _popover: ?any; + + render() { + const { card, setDatasetQuery, runQueryFn } = this.props; + if (Card.isStructured(card)) { + const query = Card.getQuery(card); + const breakouts = query && Query.getBreakouts(query); + + if (!breakouts || breakouts.length === 0) { + return null; + } + + return ( + <PopoverWithTrigger + triggerElement={ + <SelectButton hasValue> + {formatBucketing(parseFieldBucketing(breakouts[0]))} + </SelectButton> + } + triggerClasses="my2" + ref={ref => this._popover = ref} + > + <TimeGroupingPopover + className="text-brand" + field={breakouts[0]} + onFieldChange={breakout => { + let query = Card.getQuery(card); + if (query) { + query = Query.updateBreakout( + query, + 0, + breakout + ); + // $FlowFixMe + setDatasetQuery({ + ...card.dataset_query, + query + }); + runQueryFn(); + if (this._popover) { + this._popover.close(); + } + } + }} + title={null} + groupingOptions={[ + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ]} + /> + </PopoverWithTrigger> + ); + } else { + return null; + } + } +} diff --git a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7a1b560f968e7f2824cd326e9f12cd231663e5e1 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx @@ -0,0 +1,90 @@ +/* @flow */ + +import React from "react"; + +import BreakoutPopover from "metabase/qb/components/gui/BreakoutPopover"; + +import * as Card from "metabase/meta/Card"; +import Query from "metabase/lib/query"; +import { pivot } from "metabase/qb/lib/actions"; + +import type { Field } from "metabase/meta/types/Field"; +import type { + ClickAction, + ClickActionProps, + ClickActionPopoverProps +} from "metabase/meta/types/Visualization"; + +type FieldFilter = (field: Field) => boolean; + +// PivotByAction displays a breakout picker, and optionally filters by the +// clicked dimesion values (and removes corresponding breakouts) +export default (name: string, icon: string, fieldFilter: FieldFilter) => ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + const query = Card.getQuery(card); + + // Click target types: metric value + if ( + !query || + !tableMetadata || + (clicked && + (clicked.value === undefined || + clicked.column.source !== "aggregation")) + ) { + return; + } + + let dimensions = (clicked && clicked.dimensions) || []; + + const breakouts = Query.getBreakouts(query); + + const usedFields = {}; + for (const breakout of breakouts) { + usedFields[Query.getFieldTargetId(breakout)] = true; + } + + const fieldOptions = Query.getFieldOptions( + tableMetadata.fields, + true, + (fields: Field[]): Field[] => { + fields = tableMetadata.breakout_options.validFieldsFilter(fields); + if (fieldFilter) { + fields = fields.filter(fieldFilter); + } + return fields; + }, + usedFields + ); + + const customFieldOptions = Query.getExpressions(query); + + if (fieldOptions.count === 0) { + return null; + } + + return { + title: ( + <span> + Pivot by + {" "} + <span className="text-dark">{name.toLowerCase()}</span> + </span> + ), + icon: icon, + // eslint-disable-next-line react/display-name + popover: ({ onChangeCardAndRun, onClose }: ClickActionPopoverProps) => ( + <BreakoutPopover + tableMetadata={tableMetadata} + fieldOptions={fieldOptions} + customFieldOptions={customFieldOptions} + onCommitBreakout={breakout => { + onChangeCardAndRun( + pivot(card, breakout, tableMetadata, dimensions) + ); + }} + onClose={onClose} + /> + ) + }; +}; diff --git a/frontend/src/metabase/qb/components/actions/PivotByCategoryAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByCategoryAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1165f67ffc6972bb4a4f4d8658fa29c53b6d2e2b --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/PivotByCategoryAction.jsx @@ -0,0 +1,13 @@ +/* @flow */ + +import React from "react"; + +import { isCategory, isAddress } from "metabase/lib/schema_metadata"; + +import PivotByAction from "./PivotByAction"; + +export default PivotByAction( + "Category", + "label", + field => isCategory(field) && !isAddress(field) +); diff --git a/frontend/src/metabase/qb/components/actions/PivotByLocationAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByLocationAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..218ee7f08200ec2cf15853ae6efc9d7bb36b9ddf --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/PivotByLocationAction.jsx @@ -0,0 +1,9 @@ +/* @flow */ + +import React from "react"; + +import { isAddress } from "metabase/lib/schema_metadata"; + +import PivotByAction from "./PivotByAction"; + +export default PivotByAction("Location", "location", field => isAddress(field)); diff --git a/frontend/src/metabase/qb/components/actions/PivotByTimeAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByTimeAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b80c6bdc549b020ae6bfc10110594c1e0a151966 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/PivotByTimeAction.jsx @@ -0,0 +1,9 @@ +/* @flow */ + +import React from "react"; + +import { isDate } from "metabase/lib/schema_metadata"; + +import PivotByAction from "./PivotByAction"; + +export default PivotByAction("Time", "clock", field => isDate(field)); diff --git a/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx b/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx new file mode 100644 index 0000000000000000000000000000000000000000..29ab9ce34a59a78286cdf964c2a772f9c5ed8a70 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx @@ -0,0 +1,19 @@ +/* @flow */ + +import { plotSegmentField } from "metabase/qb/lib/actions"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ({ card, tableMetadata }: ClickActionProps): ?ClickAction => { + if (card.display !== "table") { + return; + } + return { + title: "Plot a field in this segment", + icon: "bar", + card: () => plotSegmentField(card) + }; +}; diff --git a/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx b/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8d05d6502b7926dc22ebae4c412c6d8e6f7d08b9 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx @@ -0,0 +1,41 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import AggregationPopover from "metabase/qb/components/gui/AggregationPopover"; + +import * as Card from "metabase/meta/Card"; +import Query from "metabase/lib/query"; +import { summarize } from "metabase/qb/lib/actions"; + +import type { + ClickAction, + ClickActionProps, + ClickActionPopoverProps +} from "metabase/meta/types/Visualization"; + +export default ({ card, tableMetadata }: ClickActionProps): ?ClickAction => { + const query = Card.getQuery(card); + if (!query) { + return; + } + + return { + title: "Summarize this segment", + icon: "funnel", // FIXME: icon + // eslint-disable-next-line react/display-name + popover: ({ onChangeCardAndRun, onClose }: ClickActionPopoverProps) => ( + <AggregationPopover + tableMetadata={tableMetadata} + customFields={Query.getExpressions(query)} + availableAggregations={tableMetadata.aggregation_options} + onCommitAggregation={aggregation => { + onChangeCardAndRun( + summarize(card, aggregation, tableMetadata) + ); + onClose && onClose(); + }} + /> + ) + }; +}; diff --git a/frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx b/frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a45bc79fd1b34e12b8ce02ab4659cfbb24772bd5 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx @@ -0,0 +1,15 @@ +/* @flow */ + +import { toUnderlyingData } from "metabase/qb/lib/actions"; + +import type { ClickActionProps } from "metabase/meta/types/Visualization"; + +export default ({ card, tableMetadata }: ClickActionProps) => { + if (card.display !== "table" && card.display !== "scalar") { + return { + title: "View the underlying data", + icon: "table", + card: () => toUnderlyingData(card) + }; + } +}; diff --git a/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx b/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d1f2fa34527dca25e47ef386399b8dd70327e292 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx @@ -0,0 +1,33 @@ +/* @flow */ + +import React from "react"; + +import { toUnderlyingRecords } from "metabase/qb/lib/actions"; +import * as Query from "metabase/lib/query/query"; +import * as Card from "metabase/meta/Card"; + +import type { ClickActionProps } from "metabase/meta/types/Visualization"; + +export default ({ card, tableMetadata }: ClickActionProps) => { + const query = Card.getQuery(card); + if (!query) { + return; + } + if (!Query.isBareRows(query)) { + return { + title: ( + <span> + View the underlying + {" "} + <span className="text-dark"> + {tableMetadata.display_name} + </span> + {" "} + records + </span> + ), + icon: "table", + card: () => toUnderlyingRecords(card) + }; + } +}; diff --git a/frontend/src/metabase/qb/components/actions/index.js b/frontend/src/metabase/qb/components/actions/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bfbda41978d864fec0e45d3d4ac9cc32fc7baa8c --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/index.js @@ -0,0 +1,6 @@ +/* @flow */ + +import UnderlyingDataAction from "./UnderlyingDataAction"; +import UnderlyingRecordsAction from "./UnderlyingRecordsAction"; + +export const DEFAULT_ACTIONS = [UnderlyingDataAction, UnderlyingRecordsAction]; diff --git a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0ffac3389573283a7626a904a0b58925f08fc6ec --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx @@ -0,0 +1,58 @@ +/* @flow */ + +import React from "react"; + +import { drillRecord } from "metabase/qb/lib/actions"; + +import { isFK, isPK } from "metabase/lib/types"; +import { singularize, stripId } from "metabase/lib/formatting"; + +import * as Table from "metabase/lib/query/table"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + if ( + !clicked || + !clicked.column || + clicked.value === undefined || + !(isFK(clicked.column.special_type) || + isPK(clicked.column.special_type)) + ) { + return; + } + + const value = clicked.value; + + let field = Table.getField(tableMetadata, clicked.column.id); + let table = tableMetadata; + let recordType = tableMetadata.display_name; + if (field.target) { + recordType = field.display_name; + table = field.target.table; + field = field.target; + } + + if (!field || !table) { + return; + } + + return { + title: ( + <span> + View this + {" "} + <span className="text-dark"> + {singularize(stripId(recordType))} + </span> + </span> + ), + default: true, + card: () => drillRecord(tableMetadata.db_id, table.id, field.id, value) + }; +}; diff --git a/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx b/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f10a4d8aa883711cd2835519fce48fa98f2feb5b --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx @@ -0,0 +1,16 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import PivotByCategoryAction from "../actions/PivotByCategoryAction"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + return PivotByCategoryAction({ card, tableMetadata, clicked }); +}; diff --git a/frontend/src/metabase/qb/components/drill/PivotByLocationDrill.jsx b/frontend/src/metabase/qb/components/drill/PivotByLocationDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..385a54969550c2c2a75eb29c69c5d10f7e8872fb --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/PivotByLocationDrill.jsx @@ -0,0 +1,16 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import PivotByLocationAction from "../actions/PivotByLocationAction"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + return PivotByLocationAction({ card, tableMetadata, clicked }); +}; diff --git a/frontend/src/metabase/qb/components/drill/PivotByTimeDrill.jsx b/frontend/src/metabase/qb/components/drill/PivotByTimeDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..110d5c12336756e9c662a951576c13c3f9636d3c --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/PivotByTimeDrill.jsx @@ -0,0 +1,16 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import PivotByTimeAction from "../actions/PivotByTimeAction"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + return PivotByTimeAction({ card, tableMetadata, clicked }); +}; diff --git a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e70a957822a696160d6d545935e4e9dbcb0e366c --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx @@ -0,0 +1,96 @@ +/* @flow */ + +import React from "react"; + +import { TYPE, isa, isFK, isPK } from "metabase/lib/types"; +import { singularize, pluralize, stripId } from "metabase/lib/formatting"; + +import { filter } from "metabase/qb/lib/actions"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +function getFiltersForColumn(column) { + if ( + isa(column.base_type, TYPE.Number) || + isa(column.base_type, TYPE.DateTime) + ) { + return [ + { name: "<", operator: "<" }, + { name: "=", operator: "=" }, + { name: "≠", operator: "!=" }, + { name: ">", operator: ">" } + ]; + } else { + return [{ name: "=", operator: "=" }, { name: "≠", operator: "!=" }]; + } +} + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + if ( + !clicked || + !clicked.column || + clicked.column.id == null || + clicked.value == undefined + ) { + return; + } + + const { value, column } = clicked; + + if (isPK(column.special_type)) { + return null; + } else if (isFK(column.special_type)) { + return { + title: ( + <span> + View this + {" "} + {singularize(stripId(column.display_name))} + 's + {" "} + {pluralize(tableMetadata.display_name)} + </span> + ), + card: () => filter(card, "=", column, value) + }; + } + + let operators = getFiltersForColumn(column); + if (!operators || operators.length === 0) { + return; + } + + return { + title: ( + <span> + Filter by this value + </span> + ), + default: true, + popover({ onChangeCardAndRun, onClose }) { + return ( + <ul className="h1 flex align-center px1"> + {operators && + operators.map(({ name, operator }) => ( + <li + key={operator} + className="p2 text-brand-hover cursor-pointer" + onClick={() => { + onChangeCardAndRun( + filter(card, operator, column, value) + ); + }} + > + {name} + </li> + ))} + </ul> + ); + } + }; +}; diff --git a/frontend/src/metabase/qb/components/drill/SortAction.jsx b/frontend/src/metabase/qb/components/drill/SortAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e37920c2ac68c365bdccb12489b9a5cdfe9ceb82 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/SortAction.jsx @@ -0,0 +1,73 @@ +/* @flow */ + +import React from "react"; + +import { assocIn } from "icepick"; +import Query from "metabase/lib/query"; +import * as Card from "metabase/meta/Card"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + const query = Card.getQuery(card); + + if ( + !query || + !clicked || + !clicked.column || + clicked.value !== undefined || + !clicked.column.source + ) { + return; + } + const { column } = clicked; + + return { + title: ( + <span> + Sort by {column.display_name} + </span> + ), + default: true, + card: () => { + let field = null; + if (column.id == null) { + // ICK. this is hacky for dealing with aggregations. need something better + // DOUBLE ICK. we also need to deal with custom fields now as well + const expressions = Query.getExpressions(query); + if (column.display_name in expressions) { + field = ["expression", column.display_name]; + } else { + field = ["aggregation", 0]; + } + } else { + field = column.id; + } + + let sortClause = [field, "ascending"]; + + if ( + query.order_by && + query.order_by.length > 0 && + query.order_by[0].length > 0 && + query.order_by[0][1] === "ascending" && + Query.isSameField(query.order_by[0][0], field) + ) { + // someone triggered another sort on the same column, so flip the sort direction + sortClause = [field, "descending"]; + } + + // set clause + return assocIn( + card, + ["dataset_query", "query", "order_by"], + [sortClause] + ); + } + }; +}; diff --git a/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..da69baae8738f4eaea433e09007bde20eaf68169 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx @@ -0,0 +1,33 @@ +/* @flow */ + +import React from "react"; + +import { pivot, drillDownForDimensions } from "metabase/qb/lib/actions"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + const dimensions = (clicked && clicked.dimensions) || []; + const drilldown = drillDownForDimensions(dimensions); + if (!drilldown) { + return; + } + + return { + title: ( + <span> + Drill into this + {" "} + <span className="text-dark"> + {drilldown.name} + </span> + </span> + ), + card: () => pivot(card, drilldown.breakout, tableMetadata, dimensions) + }; +}; diff --git a/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx b/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1f3c0d8052d73218b2b1d35ade10a2ae7d1a667c --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx @@ -0,0 +1,37 @@ +/* @flow */ + +import React from "react"; + +import { drillUnderlyingRecords } from "metabase/qb/lib/actions"; + +import { inflect } from "metabase/lib/formatting"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ?ClickAction => { + const dimensions = (clicked && clicked.dimensions) || []; + if (!clicked || dimensions.length === 0) { + return; + } + + // the metric value should be the number of rows that will be displayed + const count = typeof clicked.value === "number" ? clicked.value : 2; + + return { + title: ( + <span> + View {inflect("these", count, "this", "these")} + {" "} + <span className="text-dark"> + {inflect(tableMetadata.display_name, count)} + </span> + </span> + ), + card: () => drillUnderlyingRecords(card, dimensions) + }; +}; diff --git a/frontend/src/metabase/qb/components/drill/index.js b/frontend/src/metabase/qb/components/drill/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e02e20d305a0b46fc56700c43d0ee162f0609a55 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/index.js @@ -0,0 +1,13 @@ +/* @flow */ + +import SortAction from "./SortAction"; +import ObjectDetailDrill from "./ObjectDetailDrill"; +import QuickFilterDrill from "./QuickFilterDrill"; +import UnderlyingRecordsDrill from "./UnderlyingRecordsDrill"; + +export const DEFAULT_DRILLS = [ + SortAction, + ObjectDetailDrill, + QuickFilterDrill, + UnderlyingRecordsDrill +]; diff --git a/frontend/src/metabase/qb/components/gui/AggregationPopover.jsx b/frontend/src/metabase/qb/components/gui/AggregationPopover.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e5ff6ff997038f31d6aaf371033c6f3f9dceded0 --- /dev/null +++ b/frontend/src/metabase/qb/components/gui/AggregationPopover.jsx @@ -0,0 +1,22 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import AggPopover from "metabase/query_builder/components/AggregationPopover"; + +import type { Aggregation, ExpressionName } from "metabase/meta/types/Query"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; + +type Props = { + aggregation?: Aggregation, + tableMetadata: TableMetadata, + customFields: { [key: ExpressionName]: any }, + onCommitAggregation: (aggregation: Aggregation) => void, + onClose?: () => void +}; + +const AggregationPopover = (props: Props) => ( + <AggPopover {...props} aggregation={props.aggregation || []} /> +); + +export default AggregationPopover; diff --git a/frontend/src/metabase/qb/components/gui/BreakoutPopover.jsx b/frontend/src/metabase/qb/components/gui/BreakoutPopover.jsx new file mode 100644 index 0000000000000000000000000000000000000000..285fe299bf3f88eab7707ce725e8c95121da2897 --- /dev/null +++ b/frontend/src/metabase/qb/components/gui/BreakoutPopover.jsx @@ -0,0 +1,46 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import FieldList from "metabase/query_builder/components/FieldList.jsx"; + +import type { Breakout, ExpressionName } from "metabase/meta/types/Query"; +import type { TableMetadata, FieldOptions } from "metabase/meta/types/Metadata"; + +type Props = { + breakout?: Breakout, + tableMetadata: TableMetadata, + fieldOptions: FieldOptions, + customFieldOptions: { [key: ExpressionName]: any }, + onCommitBreakout: (breakout: Breakout) => void, + onClose?: () => void +}; + +const BreakoutPopover = ( + { + breakout, + tableMetadata, + fieldOptions, + customFieldOptions, + onCommitBreakout, + onClose + }: Props +) => ( + <FieldList + className="text-green" + tableMetadata={tableMetadata} + field={breakout} + fieldOptions={fieldOptions} + customFieldOptions={customFieldOptions} + onFieldChange={field => { + onCommitBreakout(field); + if (onClose) { + onClose(); + } + }} + enableTimeGrouping + alwaysExpanded + /> +); + +export default BreakoutPopover; diff --git a/frontend/src/metabase/qb/components/modes/DefaultMode.jsx b/frontend/src/metabase/qb/components/modes/DefaultMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6246528a33aa9192a1c6cf93c662489b76faf7f7 --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/DefaultMode.jsx @@ -0,0 +1,14 @@ +/* @flow */ + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const DefaultMode: QueryMode = { + name: "default", + actions: DEFAULT_ACTIONS, + drills: DEFAULT_DRILLS +}; + +export default DefaultMode; diff --git a/frontend/src/metabase/qb/components/modes/GeoMode.jsx b/frontend/src/metabase/qb/components/modes/GeoMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1476a911907dca60d3bbcbd37de027e8a7c8a372 --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/GeoMode.jsx @@ -0,0 +1,20 @@ +/* @flow */ + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import PivotByCategoryAction from "../actions/PivotByCategoryAction"; +import PivotByTimeAction from "../actions/PivotByTimeAction"; + +import PivotByCategoryDrill from "../drill/PivotByCategoryDrill"; +import PivotByTimeDrill from "../drill/PivotByTimeDrill"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const GeoMode: QueryMode = { + name: "geo", + actions: [...DEFAULT_ACTIONS, PivotByCategoryAction, PivotByTimeAction], + drills: [...DEFAULT_DRILLS, PivotByCategoryDrill, PivotByTimeDrill] +}; + +export default GeoMode; diff --git a/frontend/src/metabase/qb/components/modes/MetricMode.jsx b/frontend/src/metabase/qb/components/modes/MetricMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9a5109308bdf0dfc46174b9c7905bb96d392609b --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/MetricMode.jsx @@ -0,0 +1,32 @@ +/* @flow */ + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import PivotByCategoryAction from "../actions/PivotByCategoryAction"; +import PivotByLocationAction from "../actions/PivotByLocationAction"; +import PivotByTimeAction from "../actions/PivotByTimeAction"; + +import PivotByCategoryDrill from "../drill/PivotByCategoryDrill"; +import PivotByLocationDrill from "../drill/PivotByLocationDrill"; +import PivotByTimeDrill from "../drill/PivotByTimeDrill"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const MetricMode: QueryMode = { + name: "metric", + actions: [ + ...DEFAULT_ACTIONS, + PivotByCategoryAction, + PivotByLocationAction, + PivotByTimeAction + ], + drills: [ + ...DEFAULT_DRILLS, + PivotByCategoryDrill, + PivotByLocationDrill, + PivotByTimeDrill + ] +}; + +export default MetricMode; diff --git a/frontend/src/metabase/qb/components/modes/NativeMode.jsx b/frontend/src/metabase/qb/components/modes/NativeMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..abaf45d3587774a6bc70c4f867973e8868c17a1f --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/NativeMode.jsx @@ -0,0 +1,13 @@ +/* @flow */ + +import React from "react"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const NativeMode: QueryMode = { + name: "native", + actions: [], + drills: [] +}; + +export default NativeMode; diff --git a/frontend/src/metabase/qb/components/modes/PivotMode.jsx b/frontend/src/metabase/qb/components/modes/PivotMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2751893db3d9a16bfc6bc093406cc926106d269b --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/PivotMode.jsx @@ -0,0 +1,32 @@ +/* @flow */ + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import PivotByCategoryAction from "../actions/PivotByCategoryAction"; +import PivotByLocationAction from "../actions/PivotByLocationAction"; +import PivotByTimeAction from "../actions/PivotByTimeAction"; + +import PivotByCategoryDrill from "../drill/PivotByCategoryDrill"; +import PivotByLocationDrill from "../drill/PivotByLocationDrill"; +import PivotByTimeDrill from "../drill/PivotByTimeDrill"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const PivotMode: QueryMode = { + name: "pivot", + actions: [ + ...DEFAULT_ACTIONS, + PivotByCategoryAction, + PivotByLocationAction, + PivotByTimeAction + ], + drills: [ + ...DEFAULT_DRILLS, + PivotByCategoryDrill, + PivotByLocationDrill, + PivotByTimeDrill + ] +}; + +export default PivotMode; diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..974154f82bb70f8acefd6f529d8b7ceb0f38bca0 --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -0,0 +1,25 @@ +/* @flow */ + +import React from "react"; + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import SummarizeBySegmentMetricAction + from "../actions/SummarizeBySegmentMetricAction"; +// import PlotSegmentField from "../actions/PlotSegmentField"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; + +const SegmentMode: QueryMode = { + name: "segment", + actions: [ + ...DEFAULT_ACTIONS, + SummarizeBySegmentMetricAction + // commenting this out until we sort out viz settings in QB2 + // PlotSegmentField + ], + drills: [...DEFAULT_DRILLS] +}; + +export default SegmentMode; diff --git a/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e60cbf8232ba836cec1afc73637919db6915aa07 --- /dev/null +++ b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx @@ -0,0 +1,58 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import TimeseriesGroupingWidget + from "metabase/qb/components/TimeseriesGroupingWidget"; +import TimeseriesFilterWidget + from "metabase/qb/components/TimeseriesFilterWidget"; + +import { DEFAULT_ACTIONS } from "../actions"; +import { DEFAULT_DRILLS } from "../drill"; + +import PivotByCategoryAction from "../actions/PivotByCategoryAction"; +import PivotByLocationAction from "../actions/PivotByLocationAction"; + +import PivotByCategoryDrill from "../drill/PivotByCategoryDrill"; +import PivotByLocationDrill from "../drill/PivotByLocationDrill"; + +import TimeseriesPivotDrill from "../drill/TimeseriesPivotDrill"; + +import type { QueryMode } from "metabase/meta/types/Visualization"; +import type { + Card as CardObject, + DatasetQuery +} from "metabase/meta/types/Card"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; + +type Props = { + lastRunCard: CardObject, + tableMetadata: TableMetadata, + setDatasetQuery: (datasetQuery: DatasetQuery) => void, + runQueryFn: () => void +}; + +export const TimeseriesModeFooter = (props: Props) => { + return ( + <div className="flex layout-centered"> + <span className="mr1">View</span> + <TimeseriesFilterWidget {...props} card={props.lastRunCard} /> + <span className="mx1">by</span> + <TimeseriesGroupingWidget {...props} card={props.lastRunCard} /> + </div> + ); +}; + +const TimeseriesMode: QueryMode = { + name: "timeseries", + actions: [...DEFAULT_ACTIONS, PivotByCategoryAction, PivotByLocationAction], + drills: [ + ...DEFAULT_DRILLS, + TimeseriesPivotDrill, + PivotByCategoryDrill, + PivotByLocationDrill + ], + ModeFooter: TimeseriesModeFooter +}; + +export default TimeseriesMode; diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..c2667e907383815c6420c017fc44a86c0fde93b1 --- /dev/null +++ b/frontend/src/metabase/qb/lib/actions.js @@ -0,0 +1,277 @@ +/* @flow weak */ + +import moment from "moment"; + +import Q from "metabase/lib/query"; // legacy query lib +import * as Card from "metabase/meta/Card"; +import * as Query from "metabase/lib/query/query"; +import * as Field from "metabase/lib/query/field"; +import * as Filter from "metabase/lib/query/filter"; +import { startNewCard } from "metabase/lib/card"; +import { isDate, isState, isCountry } from "metabase/lib/schema_metadata"; + +import type { Card as CardObject } from "metabase/meta/types/Card"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; +import type { StructuredQuery, FieldFilter } from "metabase/meta/types/Query"; +import type { DimensionValue } from "metabase/meta/types/Visualization"; + +export const toUnderlyingData = (card: CardObject): ?CardObject => { + const newCard = startNewCard("query"); + newCard.dataset_query = card.dataset_query; + newCard.display = "table"; + return newCard; +}; + +export const toUnderlyingRecords = (card: CardObject): ?CardObject => { + if (card.dataset_query.type === "query") { + const query: StructuredQuery = card.dataset_query.query; + const newCard = startNewCard( + "query", + card.dataset_query.database, + query.source_table + ); + newCard.dataset_query.query.filter = query.filter; + return newCard; + } +}; + +export const getFieldClauseFromCol = col => { + if (col.fk_field_id != null) { + return ["fk->", col.fk_field_id, col.id]; + } else { + return ["field-id", col.id]; + } +}; + +const clone = card => { + const newCard = startNewCard("query"); + + newCard.display = card.display; + newCard.dataset_query = card.dataset_query; + newCard.visualization_settings = card.visualization_settings; + + return newCard; +}; + +// Adds a new filter with the specified operator, column, and value +export const filter = (card, operator, column, value) => { + const newCard = clone(card); + // $FlowFixMe: + const filter: FieldFilter = [ + operator, + getFieldClauseFromCol(column), + value + ]; + newCard.dataset_query.query = Query.addFilter( + newCard.dataset_query.query, + filter + ); + return newCard; +}; + +const drillFilter = (card, value, column) => { + let newCard = clone(card); + + let filter; + if (isDate(column)) { + filter = [ + "=", + [ + "datetime-field", + getFieldClauseFromCol(column), + "as", + column.unit + ], + moment(value).toISOString() + ]; + } else { + filter = ["=", getFieldClauseFromCol(column), value]; + } + + // replace existing filter, if it exists + let filters = Query.getFilters(newCard.dataset_query.query); + for (let index = 0; index < filters.length; index++) { + if ( + Filter.isFieldFilter(filters[index]) && + Field.getFieldTargetId(filters[index][1]) === column.id + ) { + newCard.dataset_query.query = Query.updateFilter( + newCard.dataset_query.query, + index, + filter + ); + return newCard; + } + } + + // otherwise add a new filter + newCard.dataset_query.query = Query.addFilter( + newCard.dataset_query.query, + filter + ); + return newCard; +}; + +const UNITS = ["minute", "hour", "day", "week", "month", "quarter", "year"]; + +export const drillDownForDimensions = dimensions => { + const timeDimensions = dimensions.filter( + dimension => dimension.column.unit + ); + if (timeDimensions.length === 1) { + const column = timeDimensions[0].column; + let nextUnit = UNITS[Math.max(0, UNITS.indexOf(column.unit) - 1)]; + if (nextUnit) { + return { + name: column.unit, + breakout: [ + "datetime-field", + getFieldClauseFromCol(column), + "as", + nextUnit + ] + }; + } + } +}; + +export const drillTimeseriesFilter = (card, value, column) => { + const newCard = drillFilter(card, value, column); + + let nextUnit = UNITS[Math.max(0, UNITS.indexOf(column.unit) - 1)]; + + newCard.dataset_query.query.breakout[0] = [ + "datetime-field", + card.dataset_query.query.breakout[0][1], + "as", + nextUnit + ]; + + return newCard; +}; + +export const drillUnderlyingRecords = (card, dimensions) => { + for (const dimension of dimensions) { + card = drillFilter(card, dimension.value, dimension.column); + } + return toUnderlyingRecords(card); +}; + +export const drillRecord = (databaseId, tableId, fieldId, value) => { + const newCard = startNewCard("query", databaseId, tableId); + newCard.dataset_query.query = Query.addFilter(newCard.dataset_query.query, [ + "=", + fieldId, + value + ]); + return newCard; +}; + +export const plotSegmentField = card => { + const newCard = startNewCard("query"); + newCard.display = "scatter"; + newCard.dataset_query = card.dataset_query; + return newCard; +}; + +export const summarize = (card, aggregation, tableMetadata) => { + const newCard = startNewCard("query"); + newCard.dataset_query = card.dataset_query; + newCard.dataset_query.query = Query.addAggregation( + newCard.dataset_query.query, + aggregation + ); + guessVisualization(newCard, tableMetadata); + return newCard; +}; + +export const pivot = ( + card: CardObject, + breakout, + tableMetadata: TableMetadata, + dimensions: DimensionValue[] = [] +): ?CardObject => { + if (card.dataset_query.type !== "query") { + return null; + } + + let newCard = startNewCard("query"); + newCard.dataset_query = card.dataset_query; + + for (const dimension of dimensions) { + newCard = drillFilter(newCard, dimension.value, dimension.column); + const breakoutFields = Query.getBreakoutFields( + newCard.dataset_query.query, + tableMetadata + ); + for (const [index, field] of breakoutFields.entries()) { + if (field && field.id === dimension.column.id) { + newCard.dataset_query.query = Query.removeBreakout( + newCard.dataset_query.query, + index + ); + } + } + } + + newCard.dataset_query.query = Query.addBreakout( + // $FlowFixMe: we know newCard is a StructuredDatasetQuery but flow doesn't + newCard.dataset_query.query, + breakout + ); + + guessVisualization(newCard, tableMetadata); + + return newCard; +}; + +// const VISUALIZATIONS_ONE_BREAKOUTS = new Set([ +// "bar", +// "line", +// "area", +// "row", +// "pie", +// "map" +// ]); +const VISUALIZATIONS_TWO_BREAKOUTS = new Set(["bar", "line", "area"]); + +const guessVisualization = (card: CardObject, tableMetadata: TableMetadata) => { + const query = Card.getQuery(card); + if (!query) { + return; + } + const aggregations = Query.getAggregations(query); + const breakoutFields = Query.getBreakouts(query).map( + breakout => (Q.getFieldTarget(breakout, tableMetadata) || {}).field + ); + if (aggregations.length === 0 && breakoutFields.length === 0) { + card.display = "table"; + } else if (aggregations.length === 1 && breakoutFields.length === 0) { + card.display = "scalar"; + } else if (aggregations.length === 1 && breakoutFields.length === 1) { + if (isState(breakoutFields[0])) { + card.display = "map"; + card.visualization_settings["map.type"] = "region"; + card.visualization_settings["map.region"] = "us_states"; + } else if (isCountry(breakoutFields[0])) { + card.display = "map"; + card.visualization_settings["map.type"] = "region"; + card.visualization_settings["map.region"] = "world_countries"; + } else if (isDate(breakoutFields[0])) { + card.display = "line"; + } else { + card.display = "bar"; + } + } else if (aggregations.length === 1 && breakoutFields.length === 2) { + if (!VISUALIZATIONS_TWO_BREAKOUTS.has(card.display)) { + if (isDate(breakoutFields[0])) { + card.display = "line"; + } else { + card.display = "bar"; + } + } + } else { + console.warn("Couldn't guess visualization", card); + card.display = "table"; + } +}; diff --git a/frontend/src/metabase/qb/lib/modes.js b/frontend/src/metabase/qb/lib/modes.js new file mode 100644 index 0000000000000000000000000000000000000000..aed8f3f378ccd0a40181d3867d63b6d004905738 --- /dev/null +++ b/frontend/src/metabase/qb/lib/modes.js @@ -0,0 +1,110 @@ +/* @flow weak */ + +import Q from "metabase/lib/query"; // legacy query lib +import { isDate, isAddress, isCategory } from "metabase/lib/schema_metadata"; +import * as Query from "metabase/lib/query/query"; +import * as Card from "metabase/meta/Card"; + +import SegmentMode from "../components/modes/SegmentMode"; +import MetricMode from "../components/modes/MetricMode"; +import TimeseriesMode from "../components/modes/TimeseriesMode"; +import GeoMode from "../components/modes/GeoMode"; +import PivotMode from "../components/modes/PivotMode"; +import NativeMode from "../components/modes/NativeMode"; +import DefaultMode from "../components/modes/DefaultMode"; + +import type { Card as CardObject } from "metabase/meta/types/Card"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; +import type { + QueryMode, + ClickAction, + ClickActionProps, + ClickObject +} from "metabase/meta/types/Visualization"; + +export function getMode( + card: CardObject, + tableMetadata: ?TableMetadata +): ?QueryMode { + if (!card) { + return null; + } + + if (Card.isNative(card)) { + return NativeMode; + } + + const query = Card.getQuery(card); + if (Card.isStructured(card) && query) { + if (!tableMetadata) { + return null; + } + + const aggregations = Query.getAggregations(query); + const breakouts = Query.getBreakouts(query); + + if (aggregations.length === 0 && breakouts.length === 0) { + return SegmentMode; + } + if (aggregations.length > 0 && breakouts.length === 0) { + return MetricMode; + } + if (aggregations.length > 0 && breakouts.length > 0) { + let breakoutFields = breakouts.map( + breakout => + (Q.getFieldTarget(breakout, tableMetadata) || {}).field + ); + if ( + (breakoutFields.length === 1 && isDate(breakoutFields[0])) || + (breakoutFields.length === 2 && + isDate(breakoutFields[0]) && + isCategory(breakoutFields[1])) + ) { + return TimeseriesMode; + } + if (breakoutFields.length === 1 && isAddress(breakoutFields[0])) { + return GeoMode; + } + if ( + (breakoutFields.length === 1 && + isCategory(breakoutFields[0])) || + (breakoutFields.length === 2 && + isCategory(breakoutFields[0]) && + isCategory(breakoutFields[1])) + ) { + return PivotMode; + } + } + } + + return DefaultMode; +} + +export const getModeActions = ( + mode: ?QueryMode, + card: ?CardObject, + tableMetadata: ?TableMetadata +): ClickAction[] => { + if (mode && card && tableMetadata) { + const props: ClickActionProps = { card, tableMetadata }; + return mode.actions + .map(actionCreator => actionCreator(props)) + .filter(action => action); + } + return []; +}; + +export const getModeDrills = ( + mode: ?QueryMode, + card: ?CardObject, + tableMetadata: ?TableMetadata, + clicked: ?ClickObject +): ClickAction[] => { + if (mode && card && tableMetadata && clicked) { + const props: ClickActionProps = { card, tableMetadata, clicked }; + return mode.drills + .map(actionCreator => actionCreator(props)) + .filter(action => action); + } + return []; +}; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 3279b92896ab93b82c4f490f99015356188582c7..390e945b3cd156cedef8154f20f4cb3746b0ecbd 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -20,7 +20,7 @@ import { getEngineNativeType, formatJsonQuery } from "metabase/lib/engine"; import { defer } from "metabase/lib/promise"; import { applyParameters } from "metabase/meta/Card"; -import { isDirty, getParameters, getNativeDatabases } from "./selectors"; +import { getParameters, getNativeDatabases } from "./selectors"; import { MetabaseApi, CardApi, UserApi } from "metabase/services"; @@ -112,7 +112,7 @@ export const updateUrl = createThunkAction(UPDATE_URL, (card, { dirty = false, r export const RESET_QB = "metabase/qb/RESET_QB"; export const resetQB = createAction(RESET_QB); -export const INITIALIZE_QB = "INITIALIZE_QB"; +export const INITIALIZE_QB = "metabase/qb/INITIALIZE_QB"; export const initializeQB = createThunkAction(INITIALIZE_QB, (location, params) => { return async (dispatch, getState) => { // do this immediately to ensure old state is cleared before the user sees it @@ -244,22 +244,22 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (location, params) }); -export const TOGGLE_DATA_REFERENCE = "TOGGLE_DATA_REFERENCE"; +export const TOGGLE_DATA_REFERENCE = "metabase/qb/TOGGLE_DATA_REFERENCE"; export const toggleDataReference = createAction(TOGGLE_DATA_REFERENCE, () => { MetabaseAnalytics.trackEvent("QueryBuilder", "Toggle Data Reference"); }); -export const TOGGLE_TEMPLATE_TAGS_EDITOR = "TOGGLE_TEMPLATE_TAGS_EDITOR"; +export const TOGGLE_TEMPLATE_TAGS_EDITOR = "metabase/qb/TOGGLE_TEMPLATE_TAGS_EDITOR"; export const toggleTemplateTagsEditor = createAction(TOGGLE_TEMPLATE_TAGS_EDITOR, () => { MetabaseAnalytics.trackEvent("QueryBuilder", "Toggle Template Tags Editor"); }); -export const CLOSE_QB_TUTORIAL = "CLOSE_QB_TUTORIAL"; +export const CLOSE_QB_TUTORIAL = "metabase/qb/CLOSE_QB_TUTORIAL"; export const closeQbTutorial = createAction(CLOSE_QB_TUTORIAL, () => { MetabaseAnalytics.trackEvent("QueryBuilder", "Tutorial Close"); }); -export const CLOSE_QB_NEWB_MODAL = "CLOSE_QB_NEWB_MODAL"; +export const CLOSE_QB_NEWB_MODAL = "metabase/qb/CLOSE_QB_NEWB_MODAL"; export const closeQbNewbModal = createThunkAction(CLOSE_QB_NEWB_MODAL, () => { return async (dispatch, getState) => { // persist the fact that this user has seen the NewbModal @@ -270,12 +270,12 @@ export const closeQbNewbModal = createThunkAction(CLOSE_QB_NEWB_MODAL, () => { }); -export const BEGIN_EDITING = "BEGIN_EDITING"; +export const BEGIN_EDITING = "metabase/qb/BEGIN_EDITING"; export const beginEditing = createAction(BEGIN_EDITING, () => { MetabaseAnalytics.trackEvent("QueryBuilder", "Edit Begin"); }); -export const CANCEL_EDITING = "CANCEL_EDITING"; +export const CANCEL_EDITING = "metabase/qb/CANCEL_EDITING"; export const cancelEditing = createThunkAction(CANCEL_EDITING, () => { return (dispatch, getState) => { const { qb: { originalCard } } = getState(); @@ -294,7 +294,7 @@ export const cancelEditing = createThunkAction(CANCEL_EDITING, () => { }; }); -export const LOAD_METADATA_FOR_CARD = "LOAD_METADATA_FOR_CARD"; +export const LOAD_METADATA_FOR_CARD = "metabase/qb/LOAD_METADATA_FOR_CARD"; export const loadMetadataForCard = createThunkAction(LOAD_METADATA_FOR_CARD, (card) => { return async (dispatch, getState) => { // if we have a card with a known source table then dispatch an action to load up that info @@ -308,7 +308,7 @@ export const loadMetadataForCard = createThunkAction(LOAD_METADATA_FOR_CARD, (ca } }); -export const LOAD_TABLE_METADATA = "LOAD_TABLE_METADATA"; +export const LOAD_TABLE_METADATA = "metabase/qb/LOAD_TABLE_METADATA"; export const loadTableMetadata = createThunkAction(LOAD_TABLE_METADATA, (tableId) => { return async (dispatch, getState) => { // if we already have the metadata loaded for the given table then we are done @@ -326,7 +326,7 @@ export const loadTableMetadata = createThunkAction(LOAD_TABLE_METADATA, (tableId }; }); -export const LOAD_DATABASE_FIELDS = "LOAD_DATABASE_FIELDS"; +export const LOAD_DATABASE_FIELDS = "metabase/qb/LOAD_DATABASE_FIELDS"; export const loadDatabaseFields = createThunkAction(LOAD_DATABASE_FIELDS, (dbId) => { return async (dispatch, getState) => { // if we already have the metadata loaded for the given table then we are done @@ -351,6 +351,14 @@ export const loadDatabaseFields = createThunkAction(LOAD_DATABASE_FIELDS, (dbId) }); function updateVisualizationSettings(card, isEditing, display, vizSettings) { + // don't need to store undefined + vizSettings = Utils.copy(vizSettings) + for (const name in vizSettings) { + if (vizSettings[name] === undefined) { + delete vizSettings[name]; + } + } + // make sure that something actually changed if (card.display === display && _.isEqual(card.visualization_settings, vizSettings)) return card; @@ -369,10 +377,10 @@ function updateVisualizationSettings(card, isEditing, display, vizSettings) { return updatedCard; } -export const SET_CARD_ATTRIBUTE = "SET_CARD_ATTRIBUTE"; +export const SET_CARD_ATTRIBUTE = "metabase/qb/SET_CARD_ATTRIBUTE"; export const setCardAttribute = createAction(SET_CARD_ATTRIBUTE, (attr, value) => ({attr, value})); -export const SET_CARD_VISUALIZATION = "SET_CARD_VISUALIZATION"; +export const SET_CARD_VISUALIZATION = "metabase/qb/SET_CARD_VISUALIZATION"; export const setCardVisualization = createThunkAction(SET_CARD_VISUALIZATION, (display) => { return (dispatch, getState) => { const { qb: { card, uiControls } } = getState(); @@ -382,7 +390,7 @@ export const setCardVisualization = createThunkAction(SET_CARD_VISUALIZATION, (d } }); -export const UPDATE_CARD_VISUALIZATION_SETTINGS = "UPDATE_CARD_VISUALIZATION_SETTINGS"; +export const UPDATE_CARD_VISUALIZATION_SETTINGS = "metabase/qb/UPDATE_CARD_VISUALIZATION_SETTINGS"; export const updateCardVisualizationSettings = createThunkAction(UPDATE_CARD_VISUALIZATION_SETTINGS, (settings) => { return (dispatch, getState) => { const { qb: { card, uiControls } } = getState(); @@ -392,7 +400,7 @@ export const updateCardVisualizationSettings = createThunkAction(UPDATE_CARD_VIS }; }); -export const REPLACE_ALL_CARD_VISUALIZATION_SETTINGS = "REPLACE_ALL_CARD_VISUALIZATION_SETTINGS"; +export const REPLACE_ALL_CARD_VISUALIZATION_SETTINGS = "metabase/qb/REPLACE_ALL_CARD_VISUALIZATION_SETTINGS"; export const replaceAllCardVisualizationSettings = createThunkAction(REPLACE_ALL_CARD_VISUALIZATION_SETTINGS, (settings) => { return (dispatch, getState) => { const { qb: { card, uiControls } } = getState(); @@ -402,7 +410,7 @@ export const replaceAllCardVisualizationSettings = createThunkAction(REPLACE_ALL }; }); -export const UPDATE_TEMPLATE_TAG = "UPDATE_TEMPLATE_TAG"; +export const UPDATE_TEMPLATE_TAG = "metabase/qb/UPDATE_TEMPLATE_TAG"; export const updateTemplateTag = createThunkAction(UPDATE_TEMPLATE_TAG, (templateTag) => { return (dispatch, getState) => { const { qb: { card, uiControls } } = getState(); @@ -420,12 +428,12 @@ export const updateTemplateTag = createThunkAction(UPDATE_TEMPLATE_TAG, (templat }; }); -export const SET_PARAMETER_VALUE = "SET_PARAMETER_VALUE"; +export const SET_PARAMETER_VALUE = "metabase/qb/SET_PARAMETER_VALUE"; export const setParameterValue = createAction(SET_PARAMETER_VALUE, (parameterId, value) => { return { id: parameterId, value }; }); -export const NOTIFY_CARD_CREATED = "NOTIFY_CARD_CREATED"; +export const NOTIFY_CARD_CREATED = "metabase/qb/NOTIFY_CARD_CREATED"; export const notifyCardCreatedFn = createThunkAction(NOTIFY_CARD_CREATED, (card) => { return (dispatch, getState) => { dispatch(updateUrl(card, { dirty: false })); @@ -436,7 +444,7 @@ export const notifyCardCreatedFn = createThunkAction(NOTIFY_CARD_CREATED, (card) } }); -export const NOTIFY_CARD_UPDATED = "NOTIFY_CARD_UPDATED"; +export const NOTIFY_CARD_UPDATED = "metabase/qb/NOTIFY_CARD_UPDATED"; export const notifyCardUpdatedFn = createThunkAction("NOTIFY_CARD_UPDATED", (card) => { return (dispatch, getState) => { dispatch(updateUrl(card, { dirty: false })); @@ -448,7 +456,7 @@ export const notifyCardUpdatedFn = createThunkAction("NOTIFY_CARD_UPDATED", (car }); // reloadCard -export const RELOAD_CARD = "RELOAD_CARD"; +export const RELOAD_CARD = "metabase/qb/RELOAD_CARD"; export const reloadCard = createThunkAction(RELOAD_CARD, () => { return async (dispatch, getState) => { const { qb: { originalCard } } = getState(); @@ -467,7 +475,7 @@ export const reloadCard = createThunkAction(RELOAD_CARD, () => { }); // setCardAndRun -export const SET_CARD_AND_RUN = "SET_CARD_AND_RUN"; +export const SET_CARD_AND_RUN = "metabase/qb/SET_CARD_AND_RUN"; export const setCardAndRun = createThunkAction(SET_CARD_AND_RUN, (runCard, shouldUpdateUrl = true) => { return async (dispatch, getState) => { // clone @@ -482,9 +490,9 @@ export const setCardAndRun = createThunkAction(SET_CARD_AND_RUN, (runCard, shoul }); -// setQuery -export const SET_QUERY = "SET_QUERY"; -export const setQuery = createThunkAction(SET_QUERY, (dataset_query, run = false) => { +// setDatasetQuery +export const SET_DATASET_QUERY = "metabase/qb/SET_DATASET_QUERY"; +export const setDatasetQuery = createThunkAction(SET_DATASET_QUERY, (dataset_query, run = false) => { return (dispatch, getState) => { const { qb: { card, uiControls, databases } } = getState(); @@ -586,7 +594,7 @@ export const setQuery = createThunkAction(SET_QUERY, (dataset_query, run = false }); // setQueryMode -export const SET_QUERY_MODE = "SET_QUERY_MODE"; +export const SET_QUERY_MODE = "metabase/qb/SET_QUERY_MODE"; export const setQueryMode = createThunkAction(SET_QUERY_MODE, (type) => { return (dispatch, getState) => { const { qb: { card, queryResult, tableMetadata, uiControls } } = getState(); @@ -650,7 +658,7 @@ export const setQueryMode = createThunkAction(SET_QUERY_MODE, (type) => { }); // setQueryDatabase -export const SET_QUERY_DATABASE = "SET_QUERY_DATABASE"; +export const SET_QUERY_DATABASE = "metabase/qb/SET_QUERY_DATABASE"; export const setQueryDatabase = createThunkAction(SET_QUERY_DATABASE, (databaseId) => { return async (dispatch, getState) => { const { qb: { card, databases, uiControls } } = getState(); @@ -698,7 +706,7 @@ export const setQueryDatabase = createThunkAction(SET_QUERY_DATABASE, (databaseI }); // setQuerySourceTable -export const SET_QUERY_SOURCE_TABLE = "SET_QUERY_SOURCE_TABLE"; +export const SET_QUERY_SOURCE_TABLE = "metabase/qb/SET_QUERY_SOURCE_TABLE"; export const setQuerySourceTable = createThunkAction(SET_QUERY_SOURCE_TABLE, (sourceTable) => { return async (dispatch, getState) => { const { qb: { card, uiControls } } = getState(); @@ -745,53 +753,104 @@ export const setQuerySourceTable = createThunkAction(SET_QUERY_SOURCE_TABLE, (so }; }); -// setQuerySort -export const SET_QUERY_SORT = "SET_QUERY_SORT"; -export const setQuerySort = createThunkAction(SET_QUERY_SORT, (column) => { - return (dispatch, getState) => { - const { qb: { card } } = getState(); - - // NOTE: we only allow this for structured type queries & we only allow sorting by a single column - if (card.dataset_query.type === "query") { - let field = null; - if (column.id == null) { - // ICK. this is hacky for dealing with aggregations. need something better - // DOUBLE ICK. we also need to deal with custom fields now as well - if (_.contains(_.keys(Query.getExpressions(card.dataset_query.query)), column.display_name)) { - field = ["expression", column.display_name]; - } else { - field = ["aggregation", 0]; - } - } else { - field = column.id; - } - - let dataset_query = Utils.copy(card.dataset_query), - sortClause = [field, "ascending"]; - - if (card.dataset_query.query.order_by && - card.dataset_query.query.order_by.length > 0 && - card.dataset_query.query.order_by[0].length > 0 && - card.dataset_query.query.order_by[0][1] === "ascending" && - Query.isSameField(card.dataset_query.query.order_by[0][0], field)) { - // someone triggered another sort on the same column, so flip the sort direction - sortClause = [field, "descending"]; +function createQueryAction(action, updaterFunction, event) { + return createThunkAction(action, (...args) => + (dispatch, getState) => { + const { qb: { card } } = getState(); + if (card.dataset_query.type === "query") { + const datasetQuery = Utils.copy(card.dataset_query); + updaterFunction(datasetQuery.query, ...args); + dispatch(setDatasetQuery(datasetQuery)); + MetabaseAnalytics.trackEvent(...(typeof event === "function" ? event(...args) : event)); } - - // set clause - dataset_query.query.order_by = [sortClause]; - - // update and run the query - dispatch(setQuery(dataset_query, true)); + return null; } + ); +} - return null; - }; -}); - +export const addQueryBreakout = createQueryAction( + "metabase/qb/ADD_QUERY_BREAKOUT", + Query.addBreakout, + ["QueryBuilder", "Add GroupBy"] +); +export const updateQueryBreakout = createQueryAction( + "metabase/qb/UPDATE_QUERY_BREAKOUT", + Query.updateBreakout, + ["QueryBuilder", "Modify GroupBy"] +); +export const removeQueryBreakout = createQueryAction( + "metabase/qb/REMOVE_QUERY_BREAKOUT", + Query.removeBreakout, + ["QueryBuilder", "Remove GroupBy"] +); +export const addQueryFilter = createQueryAction( + "metabase/qb/ADD_QUERY_FILTER", + Query.addFilter, + ["QueryBuilder", "Add Filter"] +); +export const updateQueryFilter = createQueryAction( + "metabase/qb/UPDATE_QUERY_FILTER", + Query.updateFilter, + ["QueryBuilder", "Modify Filter"] +); +export const removeQueryFilter = createQueryAction( + "metabase/qb/REMOVE_QUERY_FILTER", + Query.removeFilter, + ["QueryBuilder", "Remove Filter"] +); +export const addQueryAggregation = createQueryAction( + "metabase/qb/ADD_QUERY_AGGREGATION", + Query.addAggregation, + ["QueryBuilder", "Add Aggregation"] +); +export const updateQueryAggregation = createQueryAction( + "metabase/qb/UPDATE_QUERY_AGGREGATION", + Query.updateAggregation, + ["QueryBuilder", "Set Aggregation"] +); +export const removeQueryAggregation = createQueryAction( + "metabase/qb/REMOVE_QUERY_AGGREGATION", + Query.removeAggregation, + ["QueryBuilder", "Remove Aggregation"] +); +export const addQueryOrderBy = createQueryAction( + "metabase/qb/ADD_QUERY_ORDER_BY", + Query.addOrderBy, + ["QueryBuilder", "Add OrderBy"] +); +export const updateQueryOrderBy = createQueryAction( + "metabase/qb/UPDATE_QUERY_ORDER_BY", + Query.updateOrderBy, + ["QueryBuilder", "Set OrderBy"] +); +export const removeQueryOrderBy = createQueryAction( + "metabase/qb/REMOVE_QUERY_ORDER_BY", + Query.removeOrderBy, + ["QueryBuilder", "Remove OrderBy"] +); +export const updateQueryLimit = createQueryAction( + "metabase/qb/UPDATE_QUERY_LIMIT", + Query.updateLimit, + ["QueryBuilder", "Update Limit"] +); +export const addQueryExpression = createQueryAction( + "metabase/qb/ADD_QUERY_EXPRESSION", + Query.addExpression, + ["QueryBuilder", "Add Expression"] +); +export const updateQueryExpression = createQueryAction( + "metabase/qb/UPDATE_QUERY_EXPRESSION", + Query.updateExpression, + ["QueryBuilder", "Set Expression"] +); +export const removeQueryExpression = createQueryAction( + "metabase/qb/REMOVE_QUERY_EXPRESSION", + Query.removeExpression, + ["QueryBuilder", "Remove Expression"] +); // runQuery -export const RUN_QUERY = "RUN_QUERY"; +export const RUN_QUERY = "metabase/qb/RUN_QUERY"; export const runQuery = createThunkAction(RUN_QUERY, (card, shouldUpdateUrl = true, parameterValues, dirty) => { return async (dispatch, getState) => { const state = getState(); @@ -803,11 +862,6 @@ export const runQuery = createThunkAction(RUN_QUERY, (card, shouldUpdateUrl = tr const cardIsDirty = isCardDirty(card, state.qb.originalCard); - card = { - ...card, - dataset_query: applyParameters(card, parameters, parameterValues) - }; - if (shouldUpdateUrl) { dispatch(updateUrl(card, { dirty: cardIsDirty })); } @@ -824,11 +878,13 @@ export const runQuery = createThunkAction(RUN_QUERY, (card, shouldUpdateUrl = tr dispatch(queryErrored(startTime, error)); } + const datasetQuery = applyParameters(card, parameters, parameterValues); + // use the CardApi.query if the query is saved and not dirty so users with view but not create permissions can see it. - if (card && card.id && !isDirty(state) && dirty !== true) { - CardApi.query({ cardId: card.id, parameters: card.dataset_query.parameters }, { cancelled: cancelQueryDeferred.promise }).then(onQuerySuccess, onQueryError); + if (card.id && !cardIsDirty) { + CardApi.query({ cardId: card.id, parameters: datasetQuery.parameters }, { cancelled: cancelQueryDeferred.promise }).then(onQuerySuccess, onQueryError); } else { - MetabaseApi.dataset(card.dataset_query, { cancelled: cancelQueryDeferred.promise }).then(onQuerySuccess, onQueryError); + MetabaseApi.dataset(datasetQuery, { cancelled: cancelQueryDeferred.promise }).then(onQuerySuccess, onQueryError); } MetabaseAnalytics.trackEvent("QueryBuilder", "Run Query", card.dataset_query.type); @@ -840,7 +896,7 @@ export const runQuery = createThunkAction(RUN_QUERY, (card, shouldUpdateUrl = tr }; }); -export const QUERY_COMPLETED = "QUERY_COMPLETED"; +export const QUERY_COMPLETED = "metabase/qb/QUERY_COMPLETED"; export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryResult) => { return async (dispatch, getState) => { let cardDisplay = card.display; @@ -851,13 +907,13 @@ export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryRes if (!isScalarVisualization && queryResult.data.rows && queryResult.data.rows.length === 1 && - queryResult.data.columns.length === 1) { + queryResult.data.cols.length === 1) { // if we have a 1x1 data result then this should always be viewed as a scalar cardDisplay = "scalar"; } else if (isScalarVisualization && queryResult.data.rows && - (queryResult.data.rows.length > 1 || queryResult.data.columns.length > 1)) { + (queryResult.data.rows.length > 1 || queryResult.data.cols.length > 1)) { // any time we were a scalar and now have more than 1x1 data switch to table view cardDisplay = "table"; @@ -867,13 +923,14 @@ export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryRes } return { + card, cardDisplay, queryResult } }; }); -export const QUERY_ERRORED = "QUERY_ERRORED"; +export const QUERY_ERRORED = "metabase/qb/QUERY_ERRORED"; export const queryErrored = createThunkAction(QUERY_ERRORED, (startTime, error) => { return async (dispatch, getState) => { if (error && error.status === 0) { @@ -886,7 +943,7 @@ export const queryErrored = createThunkAction(QUERY_ERRORED, (startTime, error) }) // cancelQuery -export const CANCEL_QUERY = "CANCEL_QUERY"; +export const CANCEL_QUERY = "metabase/qb/CANCEL_QUERY"; export const cancelQuery = createThunkAction(CANCEL_QUERY, () => { return async (dispatch, getState) => { const { qb: { uiControls, queryExecutionPromise } } = getState(); @@ -898,7 +955,7 @@ export const cancelQuery = createThunkAction(CANCEL_QUERY, () => { }); // cellClicked -export const CELL_CLICKED = "CELL_CLICKED"; +export const CELL_CLICKED = "metabase/qb/CELL_CLICKED"; export const cellClicked = createThunkAction(CELL_CLICKED, (rowIndex, columnIndex, filter) => { return async (dispatch, getState) => { const { qb: { card, queryResult } } = getState(); @@ -957,14 +1014,14 @@ export const cellClicked = createThunkAction(CELL_CLICKED, (rowIndex, columnInde } // update and run the query - dispatch(setQuery(dataset_query, true)); + dispatch(setDatasetQuery(dataset_query, true)); MetabaseAnalytics.trackEvent("QueryBuilder", "Table Cell Click", "Quick Filter"); } }; }); -export const FOLLOW_FOREIGN_KEY = "FOLLOW_FOREIGN_KEY"; +export const FOLLOW_FOREIGN_KEY = "metabase/qb/FOLLOW_FOREIGN_KEY"; export const followForeignKey = createThunkAction(FOLLOW_FOREIGN_KEY, (fk) => { return async (dispatch, getState) => { const { qb: { card, queryResult } } = getState(); @@ -992,7 +1049,7 @@ export const followForeignKey = createThunkAction(FOLLOW_FOREIGN_KEY, (fk) => { }); -export const LOAD_OBJECT_DETAIL_FK_REFERENCES = "LOAD_OBJECT_DETAIL_FK_REFERENCES"; +export const LOAD_OBJECT_DETAIL_FK_REFERENCES = "metabase/qb/LOAD_OBJECT_DETAIL_FK_REFERENCES"; export const loadObjectDetailFKReferences = createThunkAction(LOAD_OBJECT_DETAIL_FK_REFERENCES, () => { return async (dispatch, getState) => { const { qb: { card, queryResult, tableForeignKeys } } = getState(); @@ -1052,8 +1109,6 @@ export const toggleDataReferenceFn = toggleDataReference; export const onBeginEditing = beginEditing; export const onCancelEditing = cancelEditing; export const setQueryModeFn = setQueryMode; -export const setSortFn = setQuerySort; -export const setQueryFn = setQuery; export const runQueryFn = runQuery; export const cancelQueryFn = cancelQuery; export const setDatabaseFn = setQueryDatabase; diff --git a/frontend/src/metabase/query_builder/components/ActionsWidget.jsx b/frontend/src/metabase/query_builder/components/ActionsWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fccb165d952d715624ed713e5424272bf87efe36 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/ActionsWidget.jsx @@ -0,0 +1,181 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon"; +import OnClickOutsideWrapper from "metabase/components/OnClickOutsideWrapper"; + +import { getModeActions } from "metabase/qb/lib/modes"; + +import cx from "classnames"; +import _ from "underscore"; + +import type { Card } from "metabase/meta/types/Card"; +import type { QueryMode, ClickAction } from "metabase/meta/types/Visualization"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; + +type Props = { + className?: string, + mode: QueryMode, + card: Card, + tableMetadata: TableMetadata, + setCardAndRun: (card: Card) => void +}; + +const CIRCLE_SIZE = 48; +const NEEDLE_SIZE = 20; +const POPOVER_WIDTH = 350; + +export default class ActionsWidget extends Component<*, Props, *> { + state = { + isVisible: false, + isOpen: false, + selectedActionIndex: null + }; + + componentWillMount() { + window.addEventListener("mousemove", this.handleMouseMoved, false); + } + + componentWillUnmount() { + window.removeEventListener("mousemove", this.handleMouseMoved, false); + } + + handleMouseMoved = () => { + if (!this.state.isVisible) { + this.setState({ isVisible: true }); + } + this.handleMouseStoppedMoving(); + }; + + handleMouseStoppedMoving = _.debounce( + () => { + this.setState({ isVisible: false }); + }, + 1000 + ); + + close = () => { + this.setState({ isOpen: false, selectedActionIndex: null }); + }; + + toggle = () => { + this.setState({ + isOpen: !this.state.isOpen, + selectedActionIndex: null + }); + }; + + handleActionClick = (index: number) => { + const { mode, card, tableMetadata } = this.props; + const action = getModeActions(mode, card, tableMetadata)[index]; + if (action && action.popover) { + this.setState({ selectedActionIndex: index }); + } else if (action && action.card) { + const card = action.card(); + if (card) { + this.props.setCardAndRun(card); + } + this.close(); + } + }; + render() { + const { className, mode, card, tableMetadata } = this.props; + const { isOpen, isVisible, selectedActionIndex } = this.state; + + const actions: ClickAction[] = getModeActions(mode, card, tableMetadata); + if (actions.length === 0) { + return null; + } + + const selectedAction: ?ClickAction = selectedActionIndex == null ? null : + actions[selectedActionIndex]; + let PopoverComponent = selectedAction && selectedAction.popover; + + return ( + <div className={cx(className, "relative")}> + <div + className="circular bg-brand flex layout-centered m4 cursor-pointer" + style={{ + width: CIRCLE_SIZE, + height: CIRCLE_SIZE, + transition: "opacity 300ms ease-in-out", + opacity: isOpen || isVisible ? 1 : 0, + boxShadow: "2px 2px 4px rgba(0, 0, 0, 0.2)" + }} + onClick={this.toggle} + > + <Icon + name="compass_needle" + className="text-white" + style={{ + transition: "transform 500ms ease-in-out", + transform: isOpen + ? "rotate(0deg)" + : "rotate(720deg)" + }} + size={NEEDLE_SIZE} + /> + </div> + {isOpen && + <OnClickOutsideWrapper handleDismissal={this.close}> + <div + className="absolute bg-white rounded bordered shadowed py1" + style={{ + width: POPOVER_WIDTH, + bottom: "50%", + right: "50%", + zIndex: -1 + }} + > + {PopoverComponent + ? <div> + <div + className="flex align-center text-grey-4 p1 px2" + > + <Icon + name="chevronleft" + className="cursor-pointer" + onClick={() => this.setState({ + selectedActionIndex: null + })} + /> + <div + className="text-centered flex-full" + > + {selectedAction && selectedAction.title} + </div> + </div> + <PopoverComponent + onChangeCardAndRun={(card) => { + if (card) { + this.props.setCardAndRun(card); + } + }} + onClose={this.close} + /> + </div> + : actions.map((action, index) => ( + <div + key={index} + className="p2 flex align-center text-grey-4 brand-hover cursor-pointer" + onClick={() => + this.handleActionClick(index)} + > + {action.icon && + <Icon + name={action.icon} + className="mr1 flex-no-shrink" + size={16} + />} + <div> + {action.title} + </div> + </div> + ))} + </div> + </OnClickOutsideWrapper>} + </div> + ); + } +} diff --git a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx index df3c35f94413985d35fefece5e6d9bf09247c0e7..d02705b9ebcca688e4dcee8dd40e0b7561e9c894 100644 --- a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx @@ -33,11 +33,12 @@ export default class AggregationPopover extends Component { static propTypes = { isNew: PropTypes.bool, aggregation: PropTypes.array, - availableAggregations: PropTypes.array.isRequired, onCommitAggregation: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, tableMetadata: PropTypes.object.isRequired, - customFields: PropTypes.object + datasetQuery: PropTypes.object, + customFields: PropTypes.object, + availableAggregations: PropTypes.array, }; @@ -75,9 +76,20 @@ export default class AggregationPopover extends Component { }); } + getAvailableAggregations() { + const { availableAggregations, tableMetadata } = this.props; + return availableAggregations || (tableMetadata && tableMetadata.aggregation_options) + } + + getCustomFields() { + const { customFields, datasetQuery } = this.props; + return customFields || (datasetQuery && Query.getExpressions(datasetQuery.query)); + } + getAggregationFieldOptions(aggOperator) { + const availableAggregations = this.getAvailableAggregations(); // NOTE: we don't use getAggregator() here because availableAggregations has the table.fields populated on the aggregation options - const aggOptions = this.props.availableAggregations.filter((o) => o.short === aggOperator); + const aggOptions = availableAggregations.filter((o) => o.short === aggOperator); if (aggOptions && aggOptions.length > 0) { return Query.getFieldOptions(this.props.tableMetadata.fields, true, aggOptions[0].validFieldsFilters[0]) } @@ -114,7 +126,11 @@ export default class AggregationPopover extends Component { } render() { - const { availableAggregations, tableMetadata } = this.props; + const { tableMetadata } = this.props; + + const customFields = this.getCustomFields(); + const availableAggregations = this.getAvailableAggregations(); + const { choosingField, editingAggregation } = this.state; const aggregation = NamedClause.getContent(this.state.aggregation); @@ -182,7 +198,7 @@ export default class AggregationPopover extends Component { startRule="aggregation" expression={aggregation} tableMetadata={tableMetadata} - customFields={this.props.customFields} + customFields={customFields} onChange={(parsedExpression) => this.setState({ aggregation: NamedClause.setContent(this.state.aggregation, parsedExpression), error: null @@ -230,7 +246,7 @@ export default class AggregationPopover extends Component { tableMetadata={tableMetadata} field={fieldId} fieldOptions={this.getAggregationFieldOptions(agg)} - customFieldOptions={this.props.customFields} + customFieldOptions={customFields} onFieldChange={this.onPickField} enableTimeGrouping={false} /> diff --git a/frontend/src/metabase/query_builder/components/DataSelector.jsx b/frontend/src/metabase/query_builder/components/DataSelector.jsx index 55f12b87d360e6489575e18f85f9c97422267001..24504e412ba6229f7f328998edaede93e7f73663 100644 --- a/frontend/src/metabase/query_builder/components/DataSelector.jsx +++ b/frontend/src/metabase/query_builder/components/DataSelector.jsx @@ -25,7 +25,7 @@ export default class DataSelector extends Component { } static propTypes = { - query: PropTypes.object.isRequired, + datasetQuery: PropTypes.object.isRequired, databases: PropTypes.array.isRequired, tables: PropTypes.array, segments: PropTypes.array, @@ -50,7 +50,7 @@ export default class DataSelector extends Component { } componentWillReceiveProps(newProps) { - let tableId = newProps.query.query && newProps.query.query.source_table; + let tableId = newProps.datasetQuery.query && newProps.datasetQuery.query.source_table; let selectedSchema; // augment databases with schemas let databases = newProps.databases && newProps.databases.map(database => { @@ -137,15 +137,15 @@ export default class DataSelector extends Component { } getSegmentId() { - return this.props.query.segment; + return this.props.datasetQuery.segment; } getDatabaseId() { - return this.props.query.database; + return this.props.datasetQuery.database; } getTableId() { - return this.props.query.query && this.props.query.query.source_table; + return this.props.datasetQuery.query && this.props.datasetQuery.query.source_table; } renderDatabasePicker() { diff --git a/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx b/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx index 43bf3544f570ecfeb43f0c9e990da516c73dfdf0..b5c13ee78ad4490114ded991d31ec7818e5ee9d3 100644 --- a/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx +++ b/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx @@ -14,26 +14,16 @@ import Query from "metabase/lib/query"; export default class ExtendedOptions extends Component { - - constructor(props, context) { - super(props, context); - - this.state = { - isOpen: false, - editExpression: null - }; - - _.bindAll( - this, - "setLimit", "addOrderBy", "updateOrderBy", "removeOrderBy" - ); - } + state = { + isOpen: false, + editExpression: null + }; static propTypes = { features: PropTypes.object.isRequired, - query: PropTypes.object.isRequired, + datasetQuery: PropTypes.object.isRequired, tableMetadata: PropTypes.object, - setQuery: PropTypes.func.isRequired + setDatasetQuery: PropTypes.func.isRequired }; static defaultProps = { @@ -41,41 +31,8 @@ export default class ExtendedOptions extends Component { }; - setLimit(limit) { - if (limit) { - Query.updateLimit(this.props.query.query, limit); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Limit'); - } else { - Query.clearLimit(this.props.query.query); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Limit'); - } - this.props.setQuery(this.props.query); - this.setState({isOpen: false}); - } - - addOrderBy() { - Query.addOrderBy(this.props.query.query, [null, "ascending"]); - this.props.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual'); - } - - updateOrderBy(index, sort) { - Query.updateOrderBy(this.props.query.query, index, sort); - this.props.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual'); - } - - removeOrderBy(index) { - Query.removeOrderBy(this.props.query.query, index); - this.props.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Sort'); - } - setExpression(name, expression, previousName) { - let query = this.props.query.query; + const { datasetQuery: { query } } = this.props; if (!_.isEmpty(previousName)) { // remove old expression using original name. this accounts for case where expression is renamed. @@ -84,23 +41,23 @@ export default class ExtendedOptions extends Component { // now add the new expression to the query Query.setExpression(query, name, expression); - this.props.setQuery(this.props.query); + this.props.setDatasetQuery(this.props.datasetQuery); this.setState({editExpression: null}); MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Expression', !_.isEmpty(previousName)); } removeExpression(name) { - let scrubbedQuery = Query.removeExpression(this.props.query.query, name); - this.props.query.query = scrubbedQuery; - this.props.setQuery(this.props.query); + let scrubbedQuery = Query.removeExpression(this.props.datasetQuery.query, name); + this.props.datasetQuery.query = scrubbedQuery; + this.props.setDatasetQuery(this.props.datasetQuery); this.setState({editExpression: null}); MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Expression'); } renderSort() { - const { query: { query }, tableMetadata } = this.props; + const { datasetQuery: { query }, tableMetadata } = this.props; if (!this.props.features.limit) { return; @@ -118,7 +75,7 @@ export default class ExtendedOptions extends Component { if (Query.isExpressionField(sort[0])) { usedExpressions[sort[0][1]] = true; } else { - usedFields[sort[0]] = true; + usedFields[Query.getFieldTargetId(sort[0])] = true; } } @@ -136,8 +93,8 @@ export default class ExtendedOptions extends Component { ) } customFieldOptions={expressions} - removeOrderBy={this.removeOrderBy.bind(null, index)} - updateOrderBy={this.updateOrderBy.bind(null, index)} + removeOrderBy={() => this.props.removeQueryOrderBy(index)} + updateOrderBy={(orderBy) => this.props.updateQueryOrderBy(index, orderBy)} /> ); @@ -151,7 +108,7 @@ export default class ExtendedOptions extends Component { const remainingExpressions = Object.keys(_.omit(expressions, usedExpressions)); if ((remainingFieldOptions.count > 0 || remainingExpressions.length > 1) && (sorts.length === 0 || sorts[sorts.length - 1][0] != null)) { - addSortButton = (<AddClauseButton text="Pick a field to sort by" onClick={this.addOrderBy} />); + addSortButton = (<AddClauseButton text="Pick a field to sort by" onClick={() => this.props.addQueryOrderBy([null, "ascending"])} />); } } @@ -170,7 +127,7 @@ export default class ExtendedOptions extends Component { // if we aren't editing any expression then there is nothing to do if (!this.state.editExpression || !this.props.tableMetadata) return null; - const query = this.props.query.query, + const query = this.props.datasetQuery.query, expressions = Query.getExpressions(query), expression = expressions && expressions[this.state.editExpression], name = _.isString(this.state.editExpression) ? this.state.editExpression : ""; @@ -192,7 +149,7 @@ export default class ExtendedOptions extends Component { renderPopover() { if (!this.state.isOpen) return null; - const { features, query, tableMetadata } = this.props; + const { features, datasetQuery, tableMetadata } = this.props; return ( <Popover onClose={() => this.setState({isOpen: false})}> @@ -201,7 +158,7 @@ export default class ExtendedOptions extends Component { {_.contains(tableMetadata.db.features, "expressions") ? <Expressions - expressions={query.query.expressions} + expressions={datasetQuery.query.expressions} tableMetadata={tableMetadata} onAddExpression={() => this.setState({isOpen: false, editExpression: true})} onEditExpression={(name) => { @@ -214,7 +171,10 @@ export default class ExtendedOptions extends Component { { features.limit && <div> <div className="mb1 h6 text-uppercase text-grey-3 text-bold">Row limit</div> - <LimitWidget limit={query.query.limit} onChange={this.setLimit} /> + <LimitWidget limit={datasetQuery.query.limit} onChange={(limit) => { + this.props.updateQueryLimit(limit); + this.setState({ isOpen: false }) + }} /> </div> } </div> diff --git a/frontend/src/metabase/query_builder/components/FieldList.jsx b/frontend/src/metabase/query_builder/components/FieldList.jsx index b4ee66a8c51e9fd049554cf681afbac498377c4f..143ad547f31595ec1e518f5f7789ddfdc3c12728 100644 --- a/frontend/src/metabase/query_builder/components/FieldList.jsx +++ b/frontend/src/metabase/query_builder/components/FieldList.jsx @@ -8,7 +8,7 @@ import TimeGroupingPopover from "./TimeGroupingPopover.jsx"; import QueryDefinitionTooltip from "./QueryDefinitionTooltip.jsx"; import { isDate, getIconForField } from 'metabase/lib/schema_metadata'; -import { parseFieldBucketing, parseFieldTarget } from "metabase/lib/query_time"; +import { parseFieldBucketing, parseFieldTargetId } from "metabase/lib/query_time"; import { stripId, singularize } from "metabase/lib/formatting"; import Query from "metabase/lib/query"; @@ -63,7 +63,7 @@ export default class FieldList extends Component { name: singularize(tableName), items: specialOptions.concat(fieldOptions.fields.map(field => ({ name: Query.getFieldPathName(field.id, tableMetadata), - value: ["field-id", field.id], + value: typeof field.id === "number" ? ["field-id", field.id] : field.id, field: field }))) }; @@ -81,8 +81,13 @@ export default class FieldList extends Component { }) })); - let sections = [mainSection].concat(fkSections); - let fieldTarget = parseFieldTarget(field); + let sections = [] + if (mainSection.items.length > 0) { + sections.push(mainSection); + } + sections.push(...fkSections); + + let fieldTarget = parseFieldTargetId(field); this.setState({ sections, fieldTarget }); } @@ -120,8 +125,7 @@ export default class FieldList extends Component { }} > <TimeGroupingPopover - field={field} - value={item.value} + field={field || ["datetime-field", item.value, "as", null]} onFieldChange={this.props.onFieldChange} groupingOptions={item.field.grouping_options} /> @@ -204,6 +208,7 @@ export default class FieldList extends Component { renderItemExtra={this.renderItemExtra} renderItemIcon={this.renderItemIcon} getItemClasses={this.getItemClasses} + alwaysExpanded={this.props.alwaysExpanded} /> ) } diff --git a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx index 2d8a36641f3b5ab4b75678405a817bcc3b839e09..d6d99f521255d3c2346f567e42fcf0597c5dd8d8 100644 --- a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx @@ -11,7 +11,6 @@ import Icon from "metabase/components/Icon.jsx"; import IconBorder from 'metabase/components/IconBorder.jsx'; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; -import MetabaseAnalytics from 'metabase/lib/analytics'; import Query from "metabase/lib/query"; import cx from "classnames"; @@ -25,19 +24,14 @@ export default class GuiQueryEditor extends Component { this.state = { expanded: true }; - - _.bindAll( - this, - "setBreakout", - ); } static propTypes = { databases: PropTypes.array, - query: PropTypes.object.isRequired, + datasetQuery: PropTypes.object.isRequired, tableMetadata: PropTypes.object, // can't be required, sometimes null isShowingDataReference: PropTypes.bool.isRequired, - setQueryFn: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, setDatabaseFn: PropTypes.func, setSourceTableFn: PropTypes.func, features: PropTypes.object @@ -54,64 +48,6 @@ export default class GuiQueryEditor extends Component { } }; - setQuery(datasetQuery) { - this.props.setQueryFn(datasetQuery); - } - - setBreakout = (index, field) => { - if (field == null) { - Query.removeBreakout(this.props.query.query, index); - this.setQuery(this.props.query); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove GroupBy'); - } else { - if (index > Query.getBreakouts(this.props.query.query) - 1) { - Query.addBreakout(this.props.query.query, field); - this.setQuery(this.props.query); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Add GroupBy'); - } else { - Query.updateBreakout(this.props.query.query, index, field); - this.setQuery(this.props.query); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify GroupBy'); - } - } - } - - updateAggregation = (index, aggregationClause) => { - Query.updateAggregation(this.props.query.query, index, aggregationClause); - this.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Aggregation', aggregationClause[0]); - } - - removeAggregation = (index, aggregationClause) => { - Query.removeAggregation(this.props.query.query, index); - this.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Aggregation', aggregationClause[0]); - } - - addFilter = (filter) => { - const query = this.props.query; - Query.addFilter(query.query, filter); - this.setQuery(query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Add Filter'); - } - - updateFilter = (index, filter) => { - Query.updateFilter(this.props.query.query, index, filter); - this.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify Filter'); - } - - removeFilter = (index) => { - Query.removeFilter(this.props.query.query, index); - this.setQuery(this.props.query); - - MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Filter'); - } - renderAdd(text, onClick, targetRefName) { let className = "AddButton text-grey-2 text-bold flex align-center text-grey-4-hover cursor-pointer no-decoration transition-color"; if (onClick) { @@ -149,19 +85,19 @@ export default class GuiQueryEditor extends Component { if (this.props.tableMetadata) { enabled = true; - let filters = Query.getFilters(this.props.query.query); + let filters = Query.getFilters(this.props.datasetQuery.query); if (filters && filters.length > 0) { filterList = ( <FilterList filters={filters} tableMetadata={this.props.tableMetadata} - removeFilter={this.removeFilter} - updateFilter={this.updateFilter} + removeFilter={this.props.removeQueryFilter} + updateFilter={this.props.updateQueryFilter} /> ); } - if (Query.canAddFilter(this.props.query.query)) { + if (Query.canAddFilter(this.props.datasetQuery.query)) { addFilterButton = this.renderAdd((filterList ? null : "Add filters to narrow your answer"), null, "addFilterTarget"); } } else { @@ -186,8 +122,8 @@ export default class GuiQueryEditor extends Component { <FilterPopover isNew={true} tableMetadata={this.props.tableMetadata || {}} - customFields={Query.getExpressions(this.props.query.query)} - onCommitFilter={this.addFilter} + customFields={Query.getExpressions(this.props.datasetQuery.query)} + onCommitFilter={this.props.addQueryFilter} onClose={() => this.refs.filterPopover.close()} /> </PopoverWithTrigger> @@ -197,7 +133,7 @@ export default class GuiQueryEditor extends Component { } renderAggregation() { - const { query: { query }, tableMetadata } = this.props; + const { datasetQuery: { query }, tableMetadata } = this.props; if (!this.props.features.aggregation) { return; @@ -226,9 +162,9 @@ export default class GuiQueryEditor extends Component { key={"agg"+index} aggregation={aggregation} tableMetadata={tableMetadata} - customFields={Query.getExpressions(this.props.query.query)} - updateAggregation={(aggregation) => this.updateAggregation(index, aggregation)} - removeAggregation={canRemoveAggregation ? this.removeAggregation.bind(null, index) : null} + customFields={Query.getExpressions(this.props.datasetQuery.query)} + updateAggregation={(aggregation) => this.props.updateQueryAggregation(index, aggregation)} + removeAggregation={canRemoveAggregation ? this.props.removeQueryAggregation.bind(null, index) : null} addButton={this.renderAdd(null)} /> ); @@ -250,7 +186,7 @@ export default class GuiQueryEditor extends Component { } renderBreakouts() { - const { query: { query }, tableMetadata, features } = this.props; + const { datasetQuery: { query }, tableMetadata, features } = this.props; if (!features.breakout) { return; @@ -264,7 +200,7 @@ export default class GuiQueryEditor extends Component { const usedFields = {}; for (const breakout of breakouts) { - usedFields[breakout] = true; + usedFields[Query.getFieldTargetId(breakout)] = true; } const remainingFieldOptions = Query.getFieldOptions(tableMetadata.fields, true, tableMetadata.breakout_options.validFieldsFilter, usedFields); @@ -287,7 +223,7 @@ export default class GuiQueryEditor extends Component { customFieldOptions={Query.getExpressions(query)} tableMetadata={tableMetadata} field={breakout} - setField={(field) => this.setBreakout(i, field)} + setField={(field) => this.props.updateQueryBreakout(i, field)} addButton={this.renderAdd(i === 0 ? "Add a grouping" : null)} /> ); @@ -315,12 +251,12 @@ export default class GuiQueryEditor extends Component { <DataSelector ref="dataSection" includeTables={true} - query={this.props.query} + datasetQuery={this.props.datasetQuery} databases={this.props.databases} tables={this.props.tables} setDatabaseFn={this.props.setDatabaseFn} setSourceTableFn={this.props.setSourceTableFn} - isInitiallyOpen={(!this.props.query.database || !this.props.query.query.source_table) && !this.props.isShowingTutorial} + isInitiallyOpen={(!this.props.datasetQuery.database || !this.props.datasetQuery.query.source_table) && !this.props.isShowingTutorial} /> : <span className="flex align-center px2 py2 text-bold text-grey"> @@ -392,8 +328,8 @@ export default class GuiQueryEditor extends Component { } render() { - const { query, databases } = this.props; - const readOnly = query.database != null && !_.findWhere(databases, { id: query.database }); + const { datasetQuery, databases } = this.props; + const readOnly = datasetQuery.database != null && !_.findWhere(databases, { id: datasetQuery.database }); if (readOnly) { return <div className="border-bottom border-med" /> } @@ -411,7 +347,6 @@ export default class GuiQueryEditor extends Component { {this.props.children} <ExtendedOptions {...this.props} - setQuery={(query) => this.setQuery(query)} /> </div> </div> diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx index fea66a4be1249faa432281a28eb3a107791894dc..a68ac9cb3ec9b4be627ff43cf2fda37e950c0809 100644 --- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx @@ -40,8 +40,8 @@ import Parameters from "metabase/dashboard/containers/Parameters"; // * `mode` : the ACE Editor mode name, e.g. 'ace/mode/json' // * `description`: name used to describe the text written in that mode, e.g. 'JSON'. Used to fill in the blank in 'This question is written in _______'. // * `requiresTable`: whether the DB selector should be a DB + Table selector. Mongo needs both DB + Table. -function getModeInfo(query, databases) { - let databaseID = query ? query.database : null, +function getModeInfo(datasetQuery, databases) { + let databaseID = datasetQuery ? datasetQuery.database : null, database = _.findWhere(databases, { id: databaseID }), engine = database ? database.engine : null; @@ -65,13 +65,13 @@ export default class NativeQueryEditor extends Component { constructor(props, context) { super(props, context); - const lines = props.query.native.query ? - Math.min(MAX_AUTO_SIZE_LINES, countLines(props.query.native.query)) : + const lines = props.datasetQuery.native.query ? + Math.min(MAX_AUTO_SIZE_LINES, countLines(props.datasetQuery.native.query)) : MAX_AUTO_SIZE_LINES; this.state = { showEditor: !(props.card && props.card.id), - modeInfo: getModeInfo(props.query, props.databases), + modeInfo: getModeInfo(props.datasetQuery, props.databases), initialHeight: getEditorLineHeight(lines) }; @@ -88,8 +88,8 @@ export default class NativeQueryEditor extends Component { card: PropTypes.object.isRequired, databases: PropTypes.array.isRequired, nativeDatabases: PropTypes.array.isRequired, - query: PropTypes.object.isRequired, - setQueryFn: PropTypes.func.isRequired, + datasetQuery: PropTypes.object.isRequired, + setDatasetQuery: PropTypes.func.isRequired, runQueryFn: PropTypes.func.isRequired, setDatabaseFn: PropTypes.func.isRequired, autocompleteResultsFn: PropTypes.func.isRequired, @@ -110,9 +110,9 @@ export default class NativeQueryEditor extends Component { } componentWillReceiveProps(nextProps) { - if (this.props.query.database !== nextProps.query.database) { + if (this.props.datasetQuery.database !== nextProps.datasetQuery.database) { this.setState({ - modeInfo: getModeInfo(nextProps.query, nextProps.databases) + modeInfo: getModeInfo(nextProps.datasetQuery, nextProps.databases) }); } } @@ -120,12 +120,12 @@ export default class NativeQueryEditor extends Component { componentDidUpdate() { const { modeInfo } = this.state; - if (this._editor.getValue() !== this.props.query.native.query) { + if (this._editor.getValue() !== this.props.datasetQuery.native.query) { // This is a weird hack, but the purpose is to avoid an infinite loop caused by the fact that calling editor.setValue() // will trigger the editor 'change' event, update the query, and cause another rendering loop which we don't want, so // we need a way to update the editor without causing the onChange event to go through as well this.localUpdate = true; - this._editor.setValue(this.props.query.native.query); + this._editor.setValue(this.props.datasetQuery.native.query); this._editor.clearSelection(); this.localUpdate = false; } @@ -179,7 +179,7 @@ export default class NativeQueryEditor extends Component { this._editor.getSession().on('change', this.onChange); // initialize the content - const querySource = this.props.query.native.query; + const querySource = this.props.datasetQuery.native.query; this._editor.setValue(querySource); this._editor.renderer.setScrollMargin(SCROLL_MARGIN, SCROLL_MARGIN); @@ -240,9 +240,9 @@ export default class NativeQueryEditor extends Component { onChange(event) { if (this._editor && !this.localUpdate) { this._updateSize(); - const { query } = this.props; - if (query.native.query !== this._editor.getValue()) { - this.props.setQueryFn(assocIn(query, ["native", "query"], this._editor.getValue())); + const { datasetQuery } = this.props; + if (datasetQuery.native.query !== this._editor.getValue()) { + this.props.setDatasetQuery(assocIn(datasetQuery, ["native", "query"], this._editor.getValue())); } } } @@ -258,13 +258,13 @@ export default class NativeQueryEditor extends Component { setTableID(tableID) { // translate the table id into the table name - let database = this.props.databases ? _.findWhere(this.props.databases, { id: this.props.query.database }) : null, + let database = this.props.databases ? _.findWhere(this.props.databases, { id: this.props.datasetQuery.database }) : null, table = database ? _.findWhere(database.tables, { id: tableID }) : null; if (table) { - const { query } = this.props; - if (query.native.collection !== table.name) { - this.props.setQueryFn(assocIn(query, ["native", "collection"], table.name)); + const { datasetQuery } = this.props; + if (datasetQuery.native.collection !== table.name) { + this.props.setDatasetQuery(assocIn(datasetQuery, ["native", "collection"], table.name)); } } } @@ -272,18 +272,18 @@ export default class NativeQueryEditor extends Component { render() { const { parameters, setParameterValue, location } = this.props; - let modeInfo = getModeInfo(this.props.query, this.props.databases); + let modeInfo = getModeInfo(this.props.datasetQuery, this.props.databases); let dataSelectors = []; if (this.state.showEditor && this.props.nativeDatabases) { // we only render a db selector if there are actually multiple to choose from - if (this.props.nativeDatabases.length > 1 && (this.props.query.database === null || _.any(this.props.nativeDatabases, (db) => db.id === this.props.query.database))) { + if (this.props.nativeDatabases.length > 1 && (this.props.datasetQuery.database === null || _.any(this.props.nativeDatabases, (db) => db.id === this.props.datasetQuery.database))) { dataSelectors.push( <div key="db_selector" className="GuiBuilder-section GuiBuilder-data flex align-center"> <span className="GuiBuilder-section-label Query-label">Database</span> <DataSelector databases={this.props.nativeDatabases} - query={this.props.query} + datasetQuery={this.props.datasetQuery} setDatabaseFn={this.setDatabaseID} /> </div> @@ -295,10 +295,10 @@ export default class NativeQueryEditor extends Component { } if (modeInfo.requiresTable) { let databases = this.props.nativeDatabases, - dbId = this.props.query.database, + dbId = this.props.datasetQuery.database, database = databases ? _.findWhere(databases, { id: dbId }) : null, tables = database ? database.tables : [], - selectedTable = this.props.query.native.collection ? _.findWhere(tables, { name: this.props.query.native.collection }) : null; + selectedTable = this.props.datasetQuery.native.collection ? _.findWhere(tables, { name: this.props.datasetQuery.native.collection }) : null; dataSelectors.push( <div key="table_selector" className="GuiBuilder-section GuiBuilder-data flex align-center"> @@ -306,7 +306,7 @@ export default class NativeQueryEditor extends Component { <DataSelector ref="dataSection" includeTables={true} - query={{ + datasetQuery={{ type: "query", query: { source_table: selectedTable ? selectedTable.id : null }, database: dbId diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index 407f1f706878bf0834691c406e6f801adb6ab7ff..337b5ac6b1f0c91c5523244ba746833e0b63483e 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; import Icon from "metabase/components/Icon.jsx"; @@ -33,13 +32,13 @@ const QueryDownloadWidget = ({ className, card, result, uuid, token }) => <div className="flex flex-row mt2"> {["csv", "json"].map(type => uuid ? - <PublicQueryButton type={type} uuid={uuid} className="mr1 text-uppercase text-default" /> + <PublicQueryButton key={type} type={type} uuid={uuid} className="mr1 text-uppercase text-default" /> : token ? - <EmbedQueryButton type={type} token={token} className="mr1 text-uppercase text-default" /> + <EmbedQueryButton key={type} type={type} token={token} className="mr1 text-uppercase text-default" /> : card && card.id ? - <SavedQueryButton type={type} card={card} result={result} className="mr1 text-uppercase text-default" /> + <SavedQueryButton key={type} type={type} card={card} result={result} className="mr1 text-uppercase text-default" /> : card && !card.id ? - <UnsavedQueryButton type={type} card={card} result={result} className="mr1 text-uppercase text-default" /> + <UnsavedQueryButton key={type} type={type} card={card} result={result} className="mr1 text-uppercase text-default" /> : null )} diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx index dd74fc57e1b346268e130c1feeaa1028565c6525..ff451023ecaa2485dfb2044d9a0cb93533fdc446 100644 --- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx +++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx @@ -295,7 +295,7 @@ export default class QueryHeader extends Component { } // parameters - if (Query.isNative(this.props.query) && database && _.contains(database.features, "native-parameters")) { + if (Query.isNative(card && card.dataset_query) && database && _.contains(database.features, "native-parameters")) { const parametersButtonClasses = cx('transition-color', { 'text-brand': this.props.uiControls.isShowingTemplateTagsEditor, 'text-brand-hover': !this.props.uiControls.isShowingTemplateTagsEditor diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx index d77c3a89a92a6844f6d4a2f8683dd08e8d3eba50..b83c10c334110d563ed19da32b8065fe63ad9520 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import { Link } from "react-router"; import LoadingSpinner from 'metabase/components/LoadingSpinner.jsx'; @@ -24,8 +23,6 @@ import _ from "underscore"; export default class QueryVisualization extends Component { constructor(props, context) { super(props, context); - this.runQuery = this.runQuery.bind(this); - this.state = this._getStateFromProps(props); } @@ -39,7 +36,6 @@ export default class QueryVisualization extends Component { setDisplayFn: PropTypes.func.isRequired, onUpdateVisualizationSettings: PropTypes.func.isRequired, onReplaceAllVisualizationSettings: PropTypes.func.isRequired, - setSortFn: PropTypes.func.isRequired, cellIsClickableFn: PropTypes.func, cellClickedFn: PropTypes.func, isRunning: PropTypes.bool.isRequired, @@ -79,12 +75,8 @@ export default class QueryVisualization extends Component { return (display !== "table" && display !== "scalar"); } - runQuery() { - this.props.runQueryFn(); - } - renderHeader() { - const { isObjectDetail, isRunning, isAdmin, card, result } = this.props; + const { isObjectDetail, isRunnable, isRunning, isAdmin, card, result, runQueryFn, cancelQueryFn } = this.props; const isDirty = this.queryIsDirty(); const isSaved = card.id != null; const isPublicLinksEnabled = MetabaseSettings.get("public_sharing"); @@ -96,11 +88,11 @@ export default class QueryVisualization extends Component { </span> <div className="absolute flex layout-centered left right z3"> <RunButton - canRun={this.props.isRunnable} + isRunnable={isRunnable} isDirty={isDirty} isRunning={isRunning} - runFn={this.runQuery} - cancelFn={this.props.cancelQueryFn} + onRun={runQueryFn} + onCancel={cancelQueryFn} /> </div> <div className="absolute right z4 flex align-center" style={{ lineHeight: 0 /* needed to align icons :-/ */ }}> @@ -143,7 +135,7 @@ export default class QueryVisualization extends Component { } render() { - const { card, databases, isObjectDetail, isRunning, result } = this.props + const { className, card, databases, isObjectDetail, isRunning, result } = this.props let viz; if (!result) { @@ -161,24 +153,25 @@ export default class QueryVisualization extends Component { onUpdateWarnings={(warnings) => this.setState({ warnings })} onOpenChartSettings={() => this.refs.settings.open()} {...this.props} + className="spread" /> ); } } - const wrapperClasses = cx('wrapper full relative mb2 z1', { + const wrapperClasses = cx(className, 'relative', { 'flex': !isObjectDetail, 'flex-column': !isObjectDetail }); - const visualizationClasses = cx('flex flex-full Visualization z1 px1', { + const visualizationClasses = cx('flex flex-full Visualization z1 relative', { 'Visualization--errors': (result && result.error), 'Visualization--loading': isRunning }); return ( <div className={wrapperClasses}> - {this.renderHeader()} + { !this.props.noHeader && this.renderHeader()} { isRunning && ( <div className="Loading spread flex flex-column layout-centered text-brand z2"> <LoadingSpinner /> diff --git a/frontend/src/metabase/query_builder/components/QuickFilterPopover.jsx b/frontend/src/metabase/query_builder/components/QuickFilterPopover.jsx deleted file mode 100644 index d63128f85631c629dddf72f11726d73a3764ac57..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/QuickFilterPopover.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; - -import Popover from "metabase/components/Popover.jsx"; -import { TYPE, isa } from "metabase/lib/types"; - -function getFiltersForColumn(column) { - if (isa(column.base_type, TYPE.Number) || isa(column.base_type, TYPE.DateTime)) { - return [ - { name: "<", value: "<" }, - { name: "=", value: "=" }, - { name: "≠", value: "!=" }, - { name: ">", value: ">" } - ]; - } else { - return [ - { name: "=", value: "=" }, - { name: "≠", value: "!=" } - ]; - } -} - -const QuickFilterPopover = ({ onFilter, onClose, column }) => - <Popover - hasArrow={false} - tetherOptions={{ - targetAttachment: "middle center", - attachment: "middle center" - }} - onClose={onClose} - > - <div className="bg-white bordered shadowed p1"> - <ul className="h1 flex align-center"> - { getFiltersForColumn(column).map(({ name, value }) => - <li key={value} className="p2 text-brand-hover" onClick={() => onFilter(value)}>{name}</li> - )} - </ul> - </div> - </Popover> - -export default QuickFilterPopover; diff --git a/frontend/src/metabase/query_builder/components/RunButton.jsx b/frontend/src/metabase/query_builder/components/RunButton.jsx index 1fd61bdc9968c519ec9ea95838c38180581dfb28..64763834590a41f8a9f0417bb436ffa28ab4793f 100644 --- a/frontend/src/metabase/query_builder/components/RunButton.jsx +++ b/frontend/src/metabase/query_builder/components/RunButton.jsx @@ -6,24 +6,24 @@ import cx from "classnames"; export default class RunButton extends Component { static propTypes = { - canRun: PropTypes.bool.isRequired, + isRunnable: PropTypes.bool.isRequired, isRunning: PropTypes.bool.isRequired, isDirty: PropTypes.bool.isRequired, - runFn: PropTypes.func.isRequired, - cancelFn: PropTypes.func + onRun: PropTypes.func.isRequired, + onCancel: PropTypes.func }; render() { - let { canRun, isRunning, isDirty, runFn, cancelFn } = this.props; + let { isRunnable, isRunning, isDirty, onRun, onCancel } = this.props; let buttonText = null; if (isRunning) { buttonText = <div className="flex align-center"><Icon className="mr1" name="close" />Cancel</div>; - } else if (canRun && isDirty) { + } else if (isRunnable && isDirty) { buttonText = "Get Answer"; - } else if (canRun && !isDirty) { + } else if (isRunnable && !isDirty) { buttonText = <div className="flex align-center"><Icon className="mr1" name="refresh" />Refresh</div>; } - let actionFn = isRunning ? cancelFn : runFn; + let actionFn = isRunning ? onCancel : onRun; let classes = cx("Button Button--medium circular RunButton", { "RunButton--hidden": !buttonText, "Button--primary": isDirty, @@ -31,7 +31,7 @@ export default class RunButton extends Component { "text-grey-4-hover": !isDirty, }); return ( - <button className={classes} onClick={actionFn}> + <button className={classes} onClick={() => actionFn()}> {buttonText} </button> ); diff --git a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx index 3d120352d8ea388e4a438422cd24ab7ece413ace..029ebb2228be2814afc33f55c52bb78ecf0c7d2e 100644 --- a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx +++ b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx @@ -32,11 +32,11 @@ export default class TimeGroupingPopover extends Component { static propTypes = { field: PropTypes.oneOfType([PropTypes.number, PropTypes.array]), - value: PropTypes.oneOfType([PropTypes.number, PropTypes.array]), onFieldChange: PropTypes.func.isRequired }; static defaultProps = { + title: "Group time by", groupingOptions: [ // "default", "minute", @@ -58,15 +58,17 @@ export default class TimeGroupingPopover extends Component { } setField(bucketing) { - this.props.onFieldChange(["datetime-field", this.props.value, "as", bucketing]); + this.props.onFieldChange(["datetime-field", this.props.field[1], "as", bucketing]); } render() { - const { field } = this.props; - const enabledOptions = new Set(this.props.groupingOptions); + const { title, field, className, groupingOptions } = this.props; + const enabledOptions = new Set(groupingOptions); return ( - <div className="px2 pt2 pb1" style={{width:"250px"}}> - <h3 className="List-section-header mx2">Group time by</h3> + <div className={cx(className, "px2 py1")} style={{width:"250px"}}> + { title && + <h3 className="List-section-header pt1 mx2">{title}</h3> + } <ul className="py1"> { BUCKETINGS.filter(o => o == null || enabledOptions.has(o)).map((bucketing, bucketingIndex) => bucketing == null ? diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx index 68f07ebcac246980e0dfc2a3b90a3cd038cf8ebb..3a6c8a94d96f2c99336e86039b8ba1d8bab8e29a 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx @@ -5,9 +5,9 @@ import QueryVisualizationObjectDetailTable from './QueryVisualizationObjectDetai import VisualizationErrorMessage from './VisualizationErrorMessage'; import Visualization from "metabase/visualizations/components/Visualization.jsx"; -const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, ...rest}) => { +const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, ...props}) => { if (isObjectDetail) { - return <QueryVisualizationObjectDetailTable data={result.data} {...rest} /> + return <QueryVisualizationObjectDetailTable data={result.data} {...props} /> } else if (result.data.rows.length === 0) { // successful query but there were 0 rows returned with the result return <VisualizationErrorMessage @@ -29,11 +29,11 @@ const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, dataset_query: lastRunDatasetQuery }; return <Visualization - className="full" series={[{ card: vizCard, data: result.data }]} + onChangeCardAndRun={props.setCardAndRun} isEditing={true} // Table: - {...rest} + {...props} /> } } @@ -43,6 +43,7 @@ VisualizationResult.propTypes = { isObjectDetail: PropTypes.bool.isRequired, lastRunDatasetQuery: PropTypes.object.isRequired, result: PropTypes.object.isRequired, + setCardAndRun: PropTypes.func, } export default VisualizationResult; diff --git a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx index 0ec813278707e512880b186cc7fcf6ac9623ce83..d706aa202c86dff9be9f120485a698f850a5cc94 100644 --- a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx @@ -27,7 +27,7 @@ export default class DataReference extends Component { query: PropTypes.object.isRequired, onClose: PropTypes.func.isRequired, runQueryFn: PropTypes.func.isRequired, - setQueryFn: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, setDatabaseFn: PropTypes.func.isRequired, setSourceTableFn: PropTypes.func.isRequired, setDisplayFn: PropTypes.func.isRequired diff --git a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx index c720a8e3af291dc208595120fed6d873f8e9c1b6..4cdde08fdb9cbf36e103bc8b713dd75058dea42d 100644 --- a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx @@ -26,10 +26,10 @@ export default class FieldPane extends Component { static propTypes = { field: PropTypes.object.isRequired, - query: PropTypes.object, + datasetQuery: PropTypes.object, loadTableAndForeignKeysFn: PropTypes.func.isRequired, runQueryFn: PropTypes.func.isRequired, - setQueryFn: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, setCardAndRun: PropTypes.func.isRequired }; @@ -47,22 +47,22 @@ export default class FieldPane extends Component { } filterBy() { - var query = this.setDatabaseAndTable(); + var datasetQuery = this.setDatabaseAndTable(); // Add an aggregation so both aggregation and filter popovers aren't visible - if (!Query.hasValidAggregation(query.query)) { - Query.clearAggregations(query.query); + if (!Query.hasValidAggregation(datasetQuery.query)) { + Query.clearAggregations(datasetQuery.query); } - Query.addFilter(query.query, [null, this.props.field.id, null]); - this.props.setQueryFn(query); + Query.addFilter(datasetQuery.query, [null, this.props.field.id, null]); + this.props.setDatasetQuery(datasetQuery); } groupBy() { - let query = this.props.query; - if (!Query.hasValidAggregation(query.query)) { - Query.clearAggregations(query.query); + let { datasetQuery } = this.props; + if (!Query.hasValidAggregation(datasetQuery.query)) { + Query.clearAggregations(datasetQuery.query); } - Query.addBreakout(query.query, this.props.field.id); - this.props.setQueryFn(query); + Query.addBreakout(datasetQuery.query, this.props.field.id); + this.props.setDatasetQuery(datasetQuery); this.props.runQueryFn(); } @@ -94,7 +94,7 @@ export default class FieldPane extends Component { } render() { - let { field, query } = this.props; + let { field, datasetQuery } = this.props; let { table, error } = this.state; let fieldName = field.display_name; @@ -111,12 +111,12 @@ export default class FieldPane extends Component { } // TODO: allow for filters/grouping via foreign keys - if (!query.query || query.query.source_table == undefined || query.query.source_table === field.table_id) { + if (!datasetQuery.query || datasetQuery.query.source_table == undefined || datasetQuery.query.source_table === field.table_id) { // NOTE: disabled this for now because we need a way to capture the completed filter before adding it to the query, or to pop open the filter widget here? // useForCurrentQuestion.push(<UseForButton title={"Filter by " + name} onClick={this.filterBy} />); // current field must be a valid breakout option for this table AND cannot already be in the breakout clause of our query - if (validBreakout && this.state.table.id === this.props.query.query.source_table && (query.query.breakout && !_.contains(query.query.breakout, field.id))) { + if (validBreakout && this.state.table.id === datasetQuery.query.source_table && (datasetQuery.query.breakout && !_.contains(datasetQuery.query.breakout, field.id))) { useForCurrentQuestion.push(<UseForButton title={"Group by " + name} onClick={this.groupBy} />); } } diff --git a/frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx b/frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx index 905bf5f8ccf8993b0d322528af99e1cb247f26f6..9c64ed132d6340e1da8a613f2c39be54e49a4a33 100644 --- a/frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx @@ -27,7 +27,7 @@ export default class MetricPane extends Component { query: PropTypes.object, loadTableAndForeignKeysFn: PropTypes.func.isRequired, runQueryFn: PropTypes.func.isRequired, - setQueryFn: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, setCardAndRun: PropTypes.func.isRequired }; diff --git a/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx b/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx index 1ca5be667f8fb63c49cb15e25c9fa52b4fc94e6f..665f1a04008872e4f2ffa428bf4d7de22d686ba6 100644 --- a/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx @@ -25,10 +25,10 @@ export default class SegmentPane extends Component { static propTypes = { segment: PropTypes.object.isRequired, - query: PropTypes.object, + datasetQuery: PropTypes.object, loadTableAndForeignKeysFn: PropTypes.func.isRequired, runQueryFn: PropTypes.func.isRequired, - setQueryFn: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, setCardAndRun: PropTypes.func.isRequired }; @@ -46,13 +46,13 @@ export default class SegmentPane extends Component { } filterBy() { - let query = this.props.query; + let { datasetQuery } = this.props; // Add an aggregation so both aggregation and filter popovers aren't visible - if (!Query.hasValidAggregation(query.query)) { - Query.clearAggregations(query.query); + if (!Query.hasValidAggregation(datasetQuery.query)) { + Query.clearAggregations(datasetQuery.query); } - Query.addFilter(query.query, ["SEGMENT", this.props.segment.id]); - this.props.setQueryFn(query); + Query.addFilter(datasetQuery.query, ["SEGMENT", this.props.segment.id]); + this.props.setDatasetQuery(datasetQuery); this.props.runQueryFn(); } @@ -77,7 +77,7 @@ export default class SegmentPane extends Component { } render() { - let { segment, query } = this.props; + let { segment, datasetQuery } = this.props; let { error, table } = this.state; let segmentName = segment.name; @@ -85,7 +85,7 @@ export default class SegmentPane extends Component { let useForCurrentQuestion = []; let usefulQuestions = []; - if (query.query && query.query.source_table === segment.table_id && !_.findWhere(Query.getFilters(query.query), { [0]: "SEGMENT", [1]: segment.id })) { + if (datasetQuery.query && datasetQuery.query.source_table === segment.table_id && !_.findWhere(Query.getFilters(datasetQuery.query), { [0]: "SEGMENT", [1]: segment.id })) { useForCurrentQuestion.push(<UseForButton title={"Filter by " + segmentName} onClick={this.filterBy} />); } diff --git a/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx b/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx index cb82754cbc81b3d0c15d77a81d6314887069492d..16eb3999fac7140f2e4c81a173ca548a267082a9 100644 --- a/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx +++ b/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx @@ -1,7 +1,7 @@ /* @flow */ import React, { Component, PropTypes } from "react"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; + import cx from "classnames"; import { titleCase } from "humanize-plus"; @@ -14,7 +14,8 @@ type Operator = { type Props = { operator: string, operators: Operator[], - onOperatorChange: (o: Operator) => void + onOperatorChange: (o: Operator) => void, + hideTimeSelectors?: bool } type State = { diff --git a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx index 7aadb04e7e3fe1ec49d7e9abf6329d809b42fb43..1f3460474b824cbce14b121188d7b1c930c2a0bd 100644 --- a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx +++ b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx @@ -22,7 +22,6 @@ import type { FieldFilter, ConcreteField, ExpressionClause } from "metabase/meta import type { TableMetadata, FieldMetadata, Operator } from "metabase/meta/types/Metadata"; type Props = { - isNew?: bool, filter?: FieldFilter, onCommitFilter: () => void, onClose: () => void, @@ -43,12 +42,11 @@ export default class FilterPopover extends Component<*, Props, State> { this.state = { // $FlowFixMe - filter: (props.isNew ? [] : props.filter) + filter: props.filter || [] }; } static propTypes = { - isNew: PropTypes.bool, filter: PropTypes.array, onCommitFilter: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, @@ -273,10 +271,9 @@ export default class FilterPopover extends Component<*, Props, State> { </div> { isDate(field) ? <DatePicker + className="mt1 border-top" filter={filter} onFilterChange={this.setFilter} - onOperatorChange={this.setOperator} - tableMetadata={this.props.tableMetadata} /> : <div> @@ -294,7 +291,7 @@ export default class FilterPopover extends Component<*, Props, State> { className={cx("Button Button--purple full", { "disabled": !this.isValid() })} onClick={() => this.commitFilter(this.state.filter)} > - {this.props.isNew ? "Add filter" : "Update filter"} + {!this.props.filter ? "Add filter" : "Update filter"} </button> </div> </div> @@ -302,11 +299,3 @@ export default class FilterPopover extends Component<*, Props, State> { } } } - -FilterPopover.propTypes = { - tableMetadata: PropTypes.object.isRequired, - isNew: PropTypes.bool, - filter: PropTypes.array, - onCommitFilter: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired -}; diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx index 4ae6fe3fe3eeadfcadcec666b6b3967e69e75a14..bfb8e9cd2d56f47d3a05674c6a7435c0a4401392 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx @@ -3,7 +3,7 @@ import React, { Component, PropTypes } from "react"; import SpecificDatePicker from "./SpecificDatePicker"; -import RelativeDatePicker, { UnitPicker } from "./RelativeDatePicker"; +import RelativeDatePicker, { DATE_PERIODS, UnitPicker } from "./RelativeDatePicker"; import DateOperatorSelector from "../DateOperatorSelector"; import Calendar from "metabase/components/Calendar"; @@ -11,6 +11,7 @@ import moment from "moment"; import Query from "metabase/lib/query"; import { mbqlEq } from "metabase/lib/query/util"; +import cx from "classnames"; import _ from "underscore"; @@ -21,15 +22,25 @@ import type { LocalFieldReference, ForeignFieldReference, ExpressionReference } from "metabase/meta/types/Query"; -const SingleDatePicker = ({ filter: [op, field, value], onFilterChange }) => - <SpecificDatePicker value={value} onChange={(value) => onFilterChange([op, field, value])} calendar /> +const SingleDatePicker = ({ filter: [op, field, value], onFilterChange, hideTimeSelectors }) => + <SpecificDatePicker + value={value} + onChange={(value) => onFilterChange([op, field, value])} + hideTimeSelectors={hideTimeSelectors} + calendar /> -const MultiDatePicker = ({ filter: [op, field, startValue, endValue], onFilterChange }) => +const MultiDatePicker = ({ filter: [op, field, startValue, endValue], onFilterChange , hideTimeSelectors}) => <div className="mx2 mb1"> <div className="flex"> - <SpecificDatePicker value={startValue} onChange={(value) => onFilterChange([op, field, value, endValue])} /> + <SpecificDatePicker + value={startValue} + hideTimeSelectors={hideTimeSelectors} + onChange={(value) => onFilterChange([op, field, value, endValue])} /> <span className="mx2 mt2">–</span> - <SpecificDatePicker value={endValue} onChange={(value) => onFilterChange([op, field, startValue, value])} /> + <SpecificDatePicker + value={endValue} + hideTimeSelectors={hideTimeSelectors} + onChange={(value) => onFilterChange([op, field, startValue, value])} /> </div> <div className="Calendar--noContext"> <Calendar @@ -48,8 +59,7 @@ const PreviousPicker = (props) => const NextPicker = (props) => <RelativeDatePicker {...props} /> - -type CurentPickerProps = { +type CurrentPickerProps = { filter: TimeIntervalFilter, onFilterChange: (filter: TimeIntervalFilter) => void }; @@ -58,8 +68,8 @@ type CurrentPickerState = { showUnits: boolean }; -class CurrentPicker extends Component<*, CurentPickerProps, CurrentPickerState> { - props: CurentPickerProps; +class CurrentPicker extends Component<*, CurrentPickerProps, CurrentPickerState> { + props: CurrentPickerProps; state: CurrentPickerState; constructor(props) { @@ -79,7 +89,8 @@ class CurrentPicker extends Component<*, CurentPickerProps, CurrentPickerState> this.setState({ showUnits: false }); }} togglePicker={() => this.setState({ showUnits: !this.state.showUnits })} - formatter={(val) => val } + formatter={(val) => val} + periods={DATE_PERIODS} /> </div> ) @@ -120,22 +131,26 @@ function getDateTimeFieldTarget(field: ConcreteField): LocalFieldReference|Forei } // wraps values in "datetime-field" is any of them have a time component -function getDateTimeFieldAndValues(filter: FieldFilter): [ConcreteField, any] { - const values = filter.slice(2).map(value => value && getDate(value)); +function getDateTimeFieldAndValues(filter: FieldFilter, count: number): [ConcreteField, any] { + const values = filter.slice(2, 2 + count).map(value => value && getDate(value)); const bucketing = _.any(values, hasTime) ? "minute" : null; const field = getDateTimeField(filter[1], bucketing); // $FlowFixMe return [field, ...values]; } + +export type OperatorName = + ("Previous"|"Next"|"Current"|"Before"|"After"|"On"|"Between"|"Is Empty"|"Not Empty"); + export type Operator = { - name: string, + name: OperatorName, widget?: any, init: (filter: FieldFilter) => any, test: (filter: FieldFilter) => boolean } -const OPERATORS: Operator[] = [ +export const DATE_OPERATORS: Operator[] = [ { name: "Previous", init: (filter) => ["time-interval", getDateTimeField(filter[1]), -getIntervals(filter), getUnit(filter)], @@ -158,28 +173,32 @@ const OPERATORS: Operator[] = [ }, { name: "Before", - init: (filter) => ["<", ...getDateTimeFieldAndValues(filter)], + init: (filter) => ["<", ...getDateTimeFieldAndValues(filter, 1)], test: ([op]) => op === "<", widget: SingleDatePicker, }, { name: "After", - init: (filter) => [">", ...getDateTimeFieldAndValues(filter)], + init: (filter) => [">", ...getDateTimeFieldAndValues(filter, 1)], test: ([op]) => op === ">", widget: SingleDatePicker, }, { name: "On", - init: (filter) => ["=", ...getDateTimeFieldAndValues(filter)], + init: (filter) => ["=", ...getDateTimeFieldAndValues(filter, 1)], test: ([op]) => op === "=", widget: SingleDatePicker, }, { name: "Between", - init: (filter) => ["BETWEEN", ...getDateTimeFieldAndValues(filter)], - test: ([op]) => op === "BETWEEN", + init: (filter) => ["BETWEEN", ...getDateTimeFieldAndValues(filter, 2)], + test: ([op]) => mbqlEq(op, "between"), widget: MultiDatePicker, }, + +]; + +export const EMPTINESS_OPERATORS: Operator[] = [ { name: "Is Empty", init: (filter) => ["IS_NULL", getDateTimeField(filter[1])], @@ -192,44 +211,56 @@ const OPERATORS: Operator[] = [ } ]; +export const ALL_OPERATORS: Operator[] = DATE_OPERATORS.concat(EMPTINESS_OPERATORS); + type Props = { + className?: string, filter: FieldFilter, onFilterChange: (filter: FieldFilter) => void, - tableMetadata: any + className: ?string, + hideEmptinessOperators: boolean, // Don't show is empty / not empty dialog + hideTimeSelectors?: boolean } export default class DatePicker extends Component<*, Props, *> { static propTypes = { filter: PropTypes.array.isRequired, onFilterChange: PropTypes.func.isRequired, - tableMetadata: PropTypes.object.isRequired + className: PropTypes.string, + hideEmptinessOperators: PropTypes.bool, + hideTimeSelectors: PropTypes.bool }; componentWillMount() { - const operator = this._getOperator() || OPERATORS[0]; + const operators = this.props.hideEmptinessOperators ? DATE_OPERATORS : ALL_OPERATORS; + + const operator = this._getOperator(operators) || operators[0]; this.props.onFilterChange(operator.init(this.props.filter)); + + this.setState({operators}) } - _getOperator() { - return _.find(OPERATORS, (o) => o.test(this.props.filter)); + _getOperator(operators: Operator[]) { + return _.find(operators, (o) => o.test(this.props.filter)); } render() { - let { filter, onFilterChange } = this.props; - const operator = this._getOperator(); + let { filter, onFilterChange, className} = this.props; + const operator = this._getOperator(this.state.operators); const Widget = operator && operator.widget; return ( - <div className="mt1 pt2 border-top"> + <div className={cx("pt2", className)}> <DateOperatorSelector operator={operator && operator.name} - operators={OPERATORS} + operators={this.state.operators} onOperatorChange={operator => onFilterChange(operator.init(filter))} /> { Widget && <Widget {...this.props} filter={filter} + hideHoursAndMinutes={this.props.hideTimeSelectors} onFilterChange={filter => { if (operator && operator.init) { onFilterChange(operator.init(filter)); diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx index 27deeff71159f1a9f957e92a54e032dbfe5daaad..589a529dc9c10ad0a879c10f09ca1b6ac84d7399 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx @@ -9,20 +9,25 @@ import NumericInput from "./NumericInput.jsx"; import type { TimeIntervalFilter, RelativeDatetimeUnit } from "metabase/meta/types/Query"; -const PERIODS: RelativeDatetimeUnit[] = [ - "minute", - "hour", +export const DATE_PERIODS: RelativeDatetimeUnit[] = [ "day", "week", "month", "year" ]; +const TIME_PERIODS: RelativeDatetimeUnit[] = [ + "minute", + "hour", +]; + +const ALL_PERIODS = DATE_PERIODS.concat(TIME_PERIODS); type Props = { filter: TimeIntervalFilter, onFilterChange: (filter: TimeIntervalFilter) => void, - formatter: (value: any) => any + formatter: (value: any) => any, + hideTimeSelectors?: boolean } type State = { @@ -43,7 +48,8 @@ export default class RelativeDatePicker extends Component<*, Props, State> { static propTypes = { filter: PropTypes.array.isRequired, onFilterChange: PropTypes.func.isRequired, - formatter: PropTypes.func.isRequired + formatter: PropTypes.func.isRequired, + hideTimeSelectors: PropTypes.bool }; static defaultProps = { @@ -74,6 +80,7 @@ export default class RelativeDatePicker extends Component<*, Props, State> { // $FlowFixMe: intervals could be a string like "current" "next" intervals={intervals} formatter={formatter} + periods={this.props.hideTimeSelectors ? DATE_PERIODS : ALL_PERIODS} /> </div> ); @@ -86,10 +93,11 @@ type UnitPickerProps = { open: bool, intervals?: number, togglePicker: () => void, - formatter: (value: ?number) => ?number + formatter: (value: ?number) => ?number, + periods: RelativeDatetimeUnit[] } -export const UnitPicker = ({ open, value, onChange, togglePicker, intervals, formatter }: UnitPickerProps) => +export const UnitPicker = ({ open, value, onChange, togglePicker, intervals, formatter, periods }: UnitPickerProps) => <div> <div onClick={() => togglePicker()} @@ -110,7 +118,7 @@ export const UnitPicker = ({ open, value, onChange, togglePicker, intervals, for overflow: 'hidden' }} > - { PERIODS.map((unit, index) => + { periods.map((unit, index) => <li className={cx( 'List-item cursor-pointer p1', diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx index 7023ee68bd6e969b067e84cb738c2866510e4d90..8b5096629906e92327da0907e198583a51227298 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx @@ -18,7 +18,8 @@ const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss"; type Props = { value: ?string, onChange: (value: ?string) => void, - calendar?: bool + calendar?: bool, + hideTimeSelectors?: bool } type State = { @@ -66,7 +67,7 @@ export default class SpecificDatePicker extends Component<*, Props, State> { } render() { - const { value, calendar } = this.props; + const { value, calendar, hideTimeSelectors } = this.props; const { showCalendar } = this.state; let date, hours, minutes; @@ -130,28 +131,30 @@ export default class SpecificDatePicker extends Component<*, Props, State> { </ExpandingContent> } - <div className={cx({ 'py2': calendar }, { 'mb3': !calendar })}> - { hours == null || minutes == null ? - <div - className="text-purple-hover cursor-pointer flex align-center" - onClick={() => this.onChange(date, 12, 30) } - > - <Icon - className="mr1" - name='clock' + { !hideTimeSelectors && + <div className={cx({'py2': calendar}, {'mb3': !calendar})}> + { hours == null || minutes == null ? + <div + className="text-purple-hover cursor-pointer flex align-center" + onClick={() => this.onChange(date, 12, 30) } + > + <Icon + className="mr1" + name='clock' + /> + Add a time + </div> + : + <HoursMinutes + clear={() => this.onChange(date, null, null)} + hours={hours} + minutes={minutes} + onChangeHours={hours => this.onChange(date, hours, minutes)} + onChangeMinutes={minutes => this.onChange(date, hours, minutes)} /> - Add a time - </div> - : - <HoursMinutes - clear={() => this.onChange(date, null, null)} - hours={hours} - minutes={minutes} - onChangeHours={hours => this.onChange(date, hours, minutes)} - onChangeMinutes={minutes => this.onChange(date, hours, minutes)} - /> - } - </div> + } + </div> + } </div> ) } diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx index 153bb3b5376e0efda390eeb26f1ce1c314bb4b17..4a43f13bf97862f1d7cadfc605e03e010a09064e 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx @@ -48,16 +48,16 @@ const EXAMPLES = { } -const TagExample = ({ datasetQuery, setQuery }) => +const TagExample = ({ datasetQuery, setDatasetQuery }) => <div> <h5>Example:</h5> <p> <Code>{datasetQuery.native.query}</Code> - { setQuery && ( + { setDatasetQuery && ( <div className="Button Button--small" data-metabase-event="QueryBuilder;Template Tag Example Query Used" - onClick={() => setQuery(datasetQuery, true) } + onClick={() => setDatasetQuery(datasetQuery, true) } > Try it </div> @@ -65,11 +65,11 @@ const TagExample = ({ datasetQuery, setQuery }) => </p> </div> -const TagEditorHelp = ({ setQuery, sampleDatasetId }) => { +const TagEditorHelp = ({ setDatasetQuery, sampleDatasetId }) => { let setQueryWithSampleDatasetId = null; if (sampleDatasetId != null) { setQueryWithSampleDatasetId = (dataset_query, run) => { - setQuery({ + setDatasetQuery({ ...dataset_query, database: sampleDatasetId }, run); @@ -91,7 +91,7 @@ const TagEditorHelp = ({ setQuery, sampleDatasetId }) => { question. When this filter widget is filled in, that value replaces the variable in the SQL template. </p> - <TagExample datasetQuery={EXAMPLES.variable} setQuery={setQueryWithSampleDatasetId} /> + <TagExample datasetQuery={EXAMPLES.variable} setDatasetQuery={setQueryWithSampleDatasetId} /> <h4>Dimensions</h4> <p> @@ -109,13 +109,13 @@ const TagEditorHelp = ({ setQuery, sampleDatasetId }) => { template. If "variable" is set, then the entire clause is placed into the template. If not, then the entire clause is ignored. </p> - <TagExample datasetQuery={EXAMPLES.optional} setQuery={setQueryWithSampleDatasetId} /> + <TagExample datasetQuery={EXAMPLES.optional} setDatasetQuery={setQueryWithSampleDatasetId} /> <p> To use multiple optional clauses you can include at least one non-optional WHERE clause followed by optional clauses starting with "AND". </p> - <TagExample datasetQuery={EXAMPLES.multipleOptional} setQuery={setQueryWithSampleDatasetId} /> + <TagExample datasetQuery={EXAMPLES.multipleOptional} setDatasetQuery={setQueryWithSampleDatasetId} /> <p> <a href="http://www.metabase.com/docs/latest/users-guide/start" target="_blank" data-metabase-event="QueryBuilder;Template Tag Documentation Click">Read the full documentation</a> diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx index 80168e06a8757362777f505ebb4ff0b2d5959566..fc385c58253f903783ba7c8a12e63dfe919e7577 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx @@ -5,6 +5,9 @@ import Toggle from "metabase/components/Toggle.jsx"; import Select, { Option } from "metabase/components/Select.jsx"; import ParameterValueWidget from "metabase/dashboard/components/parameters/ParameterValueWidget.jsx"; +import { parameterOptionsForField } from "metabase/meta/Dashboard"; +import Field from "metabase/meta/metadata/Field"; + import _ from "underscore"; export default class TagEditorParam extends Component { @@ -46,7 +49,28 @@ export default class TagEditorParam extends Component { this.props.onUpdate({ ...this.props.tag, type: type, - dimension: undefined + dimension: undefined, + widget_type: undefined + }); + } + } + + setDimension(fieldId) { + const { tag, onUpdate, databaseFields } = this.props; + const dimension = ["field-id", fieldId]; + if (!_.isEqual(tag.dimension !== dimension)) { + const field = _.findWhere(databaseFields, { id: fieldId }); + const options = parameterOptionsForField(new Field(field)); + let widget_type; + if (tag.widget_type && _.findWhere(options, { type: tag.widget_type })) { + widget_type = tag.widget_type; + } else if (options.length > 0) { + widget_type = options[0].type; + } + onUpdate({ + ...tag, + dimension, + widget_type }); } } @@ -60,11 +84,19 @@ export default class TagEditorParam extends Component { dabaseHasSchemas = schemas.length > 1; } + let widgetOptions; + if (tag.type === "dimension" && tag.dimension) { + const field = _.findWhere(databaseFields, { id: tag.dimension[1] }); + if (field) { + widgetOptions = parameterOptionsForField(new Field(field)); + } + } + return ( <div className="pb2 mb2 border-bottom border-dark"> - <h3 className="pb1">{tag.name}</h3> + <h3 className="pb2">{tag.name}</h3> - <div className="pb2"> + <div className="pb1"> <h5 className="pb1 text-normal">Filter label</h5> <input type="text" @@ -74,7 +106,7 @@ export default class TagEditorParam extends Component { /> </div> - <div className="pb2"> + <div className="pb1"> <h5 className="pb1 text-normal">Variable type</h5> <Select className="border-med bg-white block" @@ -90,36 +122,13 @@ export default class TagEditorParam extends Component { </Select> </div> - { tag.type !== "dimension" && - <div className="flex align-center pb2"> - <h5 className="text-normal mr1">Required?</h5> - <Toggle value={tag.required} onChange={(value) => this.setRequired(value)} /> - </div> - } - - { tag.type !== "dimension" && tag.required && - <div className="pb2"> - <h5 className="pb1 text-normal">Default value</h5> - <ParameterValueWidget - parameter={{ - type: tag.type === "date" ? "date/single" : null - }} - value={tag.default} - setValue={(value) => this.setParameterAttribute("default", value)} - className="AdminSelect p1 text-bold text-grey-4 bordered border-med rounded bg-white" - isEditing - commitImmediately - /> - </div> - } - { tag.type === "dimension" && - <div className="pb2"> + <div className="pb1"> <h5 className="pb1 text-normal">Field</h5> <Select className="border-med bg-white block" value={Array.isArray(tag.dimension) ? tag.dimension[1] : null} - onChange={(e) => this.setParameterAttribute("dimension", ["field-id", e.target.value])} + onChange={(e) => this.setDimension(e.target.value)} searchProp="name" searchCaseInsensitive isInitiallyOpen={!tag.dimension} @@ -137,6 +146,48 @@ export default class TagEditorParam extends Component { </div> } + + { widgetOptions && widgetOptions.length > 0 && + <div className="pb1"> + <h5 className="pb1 text-normal">Widget</h5> + <Select + className="border-med bg-white block" + value={tag.widget_type} + onChange={(e) => this.setParameterAttribute("widget_type", e.target.value)} + isInitiallyOpen={!tag.widget_type} + placeholder="Select…" + > + {[{ name: "None", type: undefined }].concat(widgetOptions).map(widgetOption => + <Option key={widgetOption.type} value={widgetOption.type}> + {widgetOption.name} + </Option> + )} + </Select> + </div> + } + + { tag.type !== "dimension" && + <div className="flex align-center pb1"> + <h5 className="text-normal mr1">Required?</h5> + <Toggle value={tag.required} onChange={(value) => this.setRequired(value)} /> + </div> + } + + { ((tag.type !== "dimension" && tag.required) || (tag.type === "dimension" || tag.widget_type)) && + <div className="pb1"> + <h5 className="pb1 text-normal">Default value</h5> + <ParameterValueWidget + parameter={{ + type: tag.widget_type || (tag.type === "date" ? "date/single" : null) + }} + value={tag.default} + setValue={(value) => this.setParameterAttribute("default", value)} + className="AdminSelect p1 text-bold text-grey-4 bordered border-med rounded bg-white" + isEditing + commitImmediately + /> + </div> + } </div> ); } diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx index 17e7c9f1b46e114d859870d582015f64907a5191..b1dadd198f9b755d50034d3747934360d678a21f 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx @@ -22,7 +22,7 @@ export default class TagEditorSidebar extends Component { onClose: PropTypes.func.isRequired, updateTemplateTag: PropTypes.func.isRequired, databaseFields: PropTypes.array, - setQuery: PropTypes.func.isRequired, + setDatasetQuery: PropTypes.func.isRequired, sampleDatasetId: PropTypes.number, }; @@ -63,7 +63,7 @@ export default class TagEditorSidebar extends Component { { section === "settings" ? <SettingsPane tags={tags} onUpdate={this.props.updateTemplateTag} databaseFields={this.props.databaseFields}/> : - <TagEditorHelp sampleDatasetId={this.props.sampleDatasetId} setQuery={this.props.setQuery}/> + <TagEditorHelp sampleDatasetId={this.props.sampleDatasetId} setDatasetQuery={this.props.setDatasetQuery}/> } </div> </div> diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 837b192c3de5d6244a9528b227abd2c617c38f2d..e0fddf9f11224e94198af92083e27dd7dae97aee 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -16,26 +16,30 @@ import QueryVisualization from "../components/QueryVisualization.jsx"; import DataReference from "../components/dataref/DataReference.jsx"; import TagEditorSidebar from "../components/template_tags/TagEditorSidebar.jsx"; import SavedQuestionIntroModal from "../components/SavedQuestionIntroModal.jsx"; +import ActionsWidget from "../components/ActionsWidget.jsx"; import { - card, - originalCard, - databases, - queryResult, - parameterValues, - isDirty, - isNew, - isObjectDetail, - tables, - tableMetadata, - tableForeignKeys, - tableForeignKeyReferences, - uiControls, + getCard, + getOriginalCard, + getLastRunCard, + getDatabases, + getQueryResult, + getParameterValues, + getIsDirty, + getIsNew, + getIsObjectDetail, + getTables, + getTableMetadata, + getTableForeignKeys, + getTableForeignKeyReferences, + getUiControls, getParametersWithValues, getDatabaseFields, getSampleDatasetId, getNativeDatabases, getIsRunnable, + getIsResultDirty, + getMode, } from "../selectors"; import { getUserIsAdmin } from "metabase/selectors/user"; @@ -70,21 +74,28 @@ const mapStateToProps = (state, props) => { isAdmin: getUserIsAdmin(state, props), fromUrl: props.location.query.from, - card: card(state), - originalCard: originalCard(state), - query: state.qb.card && state.qb.card.dataset_query, // TODO: EOL, redundant - parameterValues: parameterValues(state), - databases: databases(state), + mode: getMode(state), + + card: getCard(state), + originalCard: getOriginalCard(state), + lastRunCard: getLastRunCard(state), + + parameterValues: getParameterValues(state), + + databases: getDatabases(state), nativeDatabases: getNativeDatabases(state), - tables: tables(state), - tableMetadata: tableMetadata(state), - tableForeignKeys: tableForeignKeys(state), - tableForeignKeyReferences: tableForeignKeyReferences(state), - result: queryResult(state), - isDirty: isDirty(state), - isNew: isNew(state), - isObjectDetail: isObjectDetail(state), - uiControls: uiControls(state), + tables: getTables(state), + tableMetadata: getTableMetadata(state), + tableForeignKeys: getTableForeignKeys(state), + tableForeignKeyReferences: getTableForeignKeyReferences(state), + + result: getQueryResult(state), + + isDirty: getIsDirty(state), + isNew: getIsNew(state), + isObjectDetail: getIsObjectDetail(state), + + uiControls: getUiControls(state), parameters: getParametersWithValues(state), databaseFields: getDatabaseFields(state), sampleDatasetId: getSampleDatasetId(state), @@ -94,6 +105,7 @@ const mapStateToProps = (state, props) => { isEditing: state.qb.uiControls.isEditing, isRunning: state.qb.uiControls.isRunning, isRunnable: getIsRunnable(state), + isResultDirty: getIsResultDirty(state), loadTableAndForeignKeysFn: loadTableAndForeignKeys, autocompleteResultsFn: (prefix) => autocompleteResults(state.qb.card, prefix), @@ -118,6 +130,10 @@ export default class QueryBuilder extends Component { // TODO: React tells us that forceUpdate() is not the best thing to use, so ideally we can find a different way to trigger this this.forceUpdateDebounced = _.debounce(this.forceUpdate.bind(this), 400); + + this.state = { + legacy: true + } } componentWillMount() { @@ -170,7 +186,17 @@ export default class QueryBuilder extends Component { } render() { - const { card, isDirty, databases, uiControls } = this.props; + return ( + <div className="flex-full flex relative"> + <LegacyQueryBuilder {...this.props} /> + </div> + ) + } +} + +class LegacyQueryBuilder extends Component { + render() { + const { card, isDirty, databases, uiControls, mode } = this.props; // if we don't have a card at all or no databases then we are initializing, so keep it simple if (!card || !databases) { @@ -180,6 +206,8 @@ export default class QueryBuilder extends Component { } const showDrawer = uiControls.isShowingDataReference || uiControls.isShowingTemplateTagsEditor; + const ModeFooter = mode && mode.ModeFooter; + return ( <div className="flex-full relative"> <div className={cx("QueryBuilder flex flex-column bg-white spread", {"QueryBuilder--showSideDrawer": showDrawer})}> @@ -189,17 +217,28 @@ export default class QueryBuilder extends Component { <div id="react_qb_editor" className="z2"> { card && card.dataset_query && card.dataset_query.type === "native" ? - <NativeQueryEditor {...this.props} isOpen={!card.dataset_query.native.query || isDirty} /> + <NativeQueryEditor + {...this.props} + isOpen={!card.dataset_query.native.query || isDirty} + datasetQuery={card && card.dataset_query} + /> : <div className="wrapper"> - <GuiQueryEditor {...this.props}/> + <GuiQueryEditor + {...this.props} + datasetQuery={card && card.dataset_query} + /> </div> } </div> <div ref="viz" id="react_qb_viz" className="flex z1" style={{ "transition": "opacity 0.25s ease-in-out" }}> - <QueryVisualization {...this.props} /> + <QueryVisualization {...this.props} className="full wrapper mb2 z1" /> </div> + + { ModeFooter && + <ModeFooter {...this.props} className="flex-no-shrink" /> + } </div> <div className={cx("SideDrawer", { "SideDrawer--show": showDrawer })}> @@ -219,6 +258,8 @@ export default class QueryBuilder extends Component { { uiControls.isShowingNewbModal && <SavedQuestionIntroModal onClose={() => this.props.closeQbNewbModal()} /> } + + <ActionsWidget {...this.props} className="z2 absolute bottom right" /> </div> ); } diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 67a159764475a415bdb1890993ffb749e60b5def..7e49ee13605e1f99eecc60fddfea33c6f9540ccb 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -1,6 +1,6 @@ import Utils from "metabase/lib/utils"; import { handleActions } from "redux-actions"; -import { assoc } from "icepick"; +import { assoc, dissoc } from "icepick"; import { RESET_QB, @@ -28,7 +28,7 @@ import { SET_QUERY_DATABASE, SET_QUERY_SOURCE_TABLE, SET_QUERY_MODE, - SET_QUERY, + SET_DATASET_QUERY, RUN_QUERY, CANCEL_QUERY, QUERY_COMPLETED, @@ -49,7 +49,7 @@ export const uiControls = handleActions({ [TOGGLE_DATA_REFERENCE]: { next: (state, { payload }) => ({ ...state, isShowingDataReference: !state.isShowingDataReference, isShowingTemplateTagsEditor: false }) }, [TOGGLE_TEMPLATE_TAGS_EDITOR]: { next: (state, { payload }) => ({ ...state, isShowingTemplateTagsEditor: !state.isShowingTemplateTagsEditor, isShowingDataReference: false }) }, - [SET_QUERY]: { next: (state, { payload }) => ({ ...state, isShowingTemplateTagsEditor: payload.openTemplateTagsEditor }) }, + [SET_DATASET_QUERY]: { next: (state, { payload }) => ({ ...state, isShowingTemplateTagsEditor: payload.openTemplateTagsEditor }) }, [CLOSE_QB_TUTORIAL]: { next: (state, { payload }) => ({ ...state, isShowingTutorial: false }) }, [CLOSE_QB_NEWB_MODAL]: { next: (state, { payload }) => ({ ...state, isShowingNewbModal: false }) }, @@ -92,7 +92,7 @@ export const card = handleActions({ [SET_QUERY_MODE]: { next: (state, { payload }) => payload }, [SET_QUERY_DATABASE]: { next: (state, { payload }) => payload }, [SET_QUERY_SOURCE_TABLE]: { next: (state, { payload }) => payload }, - [SET_QUERY]: { next: (state, { payload }) => payload.card }, + [SET_DATASET_QUERY]: { next: (state, { payload }) => payload.card }, [QUERY_COMPLETED]: { next: (state, { payload }) => ({ ...state, display: payload.cardDisplay }) }, @@ -141,6 +141,11 @@ export const tableForeignKeyReferences = handleActions({ [LOAD_OBJECT_DETAIL_FK_REFERENCES]: { next: (state, { payload }) => payload} }, null); +export const lastRunCard = handleActions({ + [RESET_QB]: { next: (state, { payload }) => null }, + [QUERY_COMPLETED]: { next: (state, { payload }) => payload.card }, + [QUERY_ERRORED]: { next: (state, { payload }) => null }, +}, null); // the result of a query execution. optionally an error if the query fails to complete successfully. export const queryResult = handleActions({ @@ -158,7 +163,7 @@ export const queryExecutionPromise = handleActions({ }, null); export const parameterValues = handleActions({ - [SET_PARAMETER_VALUE]: { next: (state, { payload: { id, value }}) => assoc(state, id, value) } + [SET_PARAMETER_VALUE]: { next: (state, { payload: { id, value }}) => value == null ? dissoc(state, id) : assoc(state, id, value) } }, {}); export const currentState = handleActions({ diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index de951ce0c3c2bebac3a1aae70d09436582e208cc..9f3472fe8c25ac37b183a8b487b70fdb132c6afb 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -6,36 +6,40 @@ import { getTemplateTags } from "metabase/meta/Card"; import { getTemplateTagParameters } from "metabase/meta/Parameter"; import { isCardDirty, isCardRunnable } from "metabase/lib/card"; -import { parseFieldTarget } from "metabase/lib/query_time"; +import { parseFieldTargetId } from "metabase/lib/query_time"; import { isPK } from "metabase/lib/types"; import Query from "metabase/lib/query"; +import Utils from "metabase/lib/utils"; -export const uiControls = state => state.qb.uiControls; +export const getUiControls = state => state.qb.uiControls; -export const card = state => state.qb.card; -export const originalCard = state => state.qb.originalCard; -export const parameterValues = state => state.qb.parameterValues; +export const getCard = state => state.qb.card; +export const getOriginalCard = state => state.qb.originalCard; +export const getLastRunCard = state => state.qb.lastRunCard; -export const isDirty = createSelector( - [card, originalCard], +export const getParameterValues = state => state.qb.parameterValues; +export const getQueryResult = state => state.qb.queryResult; + +export const getIsDirty = createSelector( + [getCard, getOriginalCard], (card, originalCard) => { return isCardDirty(card, originalCard); } ); -export const isNew = (state) => state.qb.card && !state.qb.card.id; +export const getIsNew = (state) => state.qb.card && !state.qb.card.id; export const getDatabaseId = createSelector( - [card], + [getCard], (card) => card && card.dataset_query && card.dataset_query.database ); -export const databases = state => state.qb.databases; -export const tableForeignKeys = state => state.qb.tableForeignKeys; -export const tableForeignKeyReferences = state => state.qb.tableForeignKeyReferences; +export const getDatabases = state => state.qb.databases; +export const getTableForeignKeys = state => state.qb.tableForeignKeys; +export const getTableForeignKeyReferences = state => state.qb.tableForeignKeyReferences; -export const tables = createSelector( - [getDatabaseId, databases], +export const getTables = createSelector( + [getDatabaseId, getDatabases], (databaseId, databases) => { if (databaseId != null && databases && databases.length > 0) { let db = _.findWhere(databases, { id: databaseId }); @@ -49,14 +53,13 @@ export const tables = createSelector( ); export const getNativeDatabases = createSelector( - databases, + [getDatabases], (databases) => databases && databases.filter(db => db.native_permissions === "write") ) -export const tableMetadata = createSelector( - state => state.qb.tableMetadata, - databases, +export const getTableMetadata = createSelector( + [state => state.qb.tableMetadata, getDatabases], (tableMetadata, databases) => tableMetadata && { ...tableMetadata, db: _.findWhere(databases, { id: tableMetadata.db_id }) @@ -64,7 +67,7 @@ export const tableMetadata = createSelector( ) export const getSampleDatasetId = createSelector( - [databases], + [getDatabases], (databases) => { const sampleDataset = _.findWhere(databases, { is_sample: true }); return sampleDataset && sampleDataset.id; @@ -76,8 +79,8 @@ export const getDatabaseFields = createSelector( (databaseId, databaseFields) => databaseFields[databaseId] ); -export const isObjectDetail = createSelector( - [state => state.qb.queryResult], +export const getIsObjectDetail = createSelector( + [getQueryResult], (queryResult) => { if (!queryResult || !queryResult.json_query) { return false; @@ -114,7 +117,7 @@ export const isObjectDetail = createSelector( if (Array.isArray(filter) && filter.length === 3 && filter[0] === "=" && - parseFieldTarget(filter[1]) === pkField && + parseFieldTargetId(filter[1]) === pkField && filter[2] !== null) { // well, all of our conditions have passed so we have an object detail query here response = true; @@ -127,24 +130,36 @@ export const isObjectDetail = createSelector( } ); -export const queryResult = createSelector( - [state => state.qb.queryResult], - (queryResult) => queryResult -); + + +import { getMode as getMode_ } from "metabase/qb/lib/modes"; + +export const getMode = createSelector( + [getLastRunCard, getTableMetadata], + (card, tableMetadata) => getMode_(card, tableMetadata) +) export const getImplicitParameters = createSelector( - [card], + [getCard], (card) => getTemplateTagParameters(getTemplateTags(card)) ); +export const getModeParameters = createSelector( + [getLastRunCard, getTableMetadata, getMode], + (card, tableMetadata, mode) => + (card && tableMetadata && mode && mode.getModeParameters) ? + mode.getModeParameters(card, tableMetadata) : + [] +); + export const getParameters = createSelector( - [getImplicitParameters], - (implicitParameters) => implicitParameters + [getModeParameters, getImplicitParameters], + (modeParameters, implicitParameters) => [...modeParameters, ...implicitParameters] ); export const getParametersWithValues = createSelector( - [getParameters, parameterValues], + [getParameters, getParameterValues], (parameters, values) => parameters.map(parameter => ({ ...parameter, @@ -153,6 +168,20 @@ export const getParametersWithValues = createSelector( ); export const getIsRunnable = createSelector( - [card, tableMetadata], + [getCard, getTableMetadata], (card, tableMetadata) => isCardRunnable(card, tableMetadata) ) + +const getLastRunDatasetQuery = createSelector([getLastRunCard], (card) => card && card.dataset_query); +const getNextRunDatasetQuery = createSelector([getCard], (card) => card && card.dataset_query); + +const getLastRunParameters = createSelector([getQueryResult], (queryResult) => queryResult && queryResult.json_query.parameters || []) +const getLastRunParameterValues = createSelector([getLastRunParameters], (parameters) => parameters.map(parameter => parameter.value)) +const getNextRunParameterValues = createSelector([getParametersWithValues], (parameters) => parameters.map(parameter => parameter.value)) + +export const getIsResultDirty = createSelector( + [getLastRunDatasetQuery, getNextRunDatasetQuery, getLastRunParameterValues, getNextRunParameterValues], + (lastDatasetQuery, nextDatasetQuery, lastParameters, nextParameters) => { + return !Utils.equals(lastDatasetQuery, nextDatasetQuery) || !Utils.equals(lastParameters, nextParameters); + } +) diff --git a/frontend/src/metabase/reference/components/GuideDetailEditor.jsx b/frontend/src/metabase/reference/components/GuideDetailEditor.jsx index 69070f0ebbb2c46801983a2e15abcbe230603e26..cc3eb93c86938cd50f407ac933ca224d2971f84e 100644 --- a/frontend/src/metabase/reference/components/GuideDetailEditor.jsx +++ b/frontend/src/metabase/reference/components/GuideDetailEditor.jsx @@ -60,7 +60,7 @@ const GuideDetailEditor = ({ </div> <div className="py2"> { entities ? - <Select + <Select value={entities[formField.id.value]} options={Object.values(entities)} disabledOptionIds={selectedIds} @@ -83,7 +83,7 @@ const GuideDetailEditor = ({ className={cx(selectClasses, 'inline-block', 'rounded', 'text-bold')} triggerIconSize={12} includeTables={true} - query={{ + datasetQuery={{ query: { source_table: formField.type.value === 'table' && Number.parseInt(formField.id.value) @@ -99,7 +99,7 @@ const GuideDetailEditor = ({ databases={ Object.values(databases) .map(database => ({ - ...database, + ...database, tables: database.tables.map(tableId => tables[tableId]) })) } @@ -110,7 +110,7 @@ const GuideDetailEditor = ({ .map(idTypePair => idTypePair[0]) } setSourceTableFn={(tableId) => { - const table = tables[tableId]; + const table = tables[tableId]; formField.id.onChange(table.id); formField.type.onChange('table'); formField.points_of_interest.onChange(table.points_of_interest || ''); @@ -122,7 +122,7 @@ const GuideDetailEditor = ({ .map(idTypePair => idTypePair[0]) } setSourceSegmentFn={(segmentId) => { - const segment = segments[segmentId]; + const segment = segments[segmentId]; formField.id.onChange(segment.id); formField.type.onChange('segment'); formField.points_of_interest.onChange(segment.points_of_interest || ''); @@ -147,10 +147,10 @@ const GuideDetailEditor = ({ <EditLabel> { type === 'dashboard' ? `Why is this dashboard the most important?` : - `What is useful or interesting about this ${type}?` + `What is useful or interesting about this ${type}?` } </EditLabel> - <textarea + <textarea className={S.guideDetailEditorTextarea} placeholder="Write something helpful here" {...formField.points_of_interest} @@ -162,17 +162,17 @@ const GuideDetailEditor = ({ <EditLabel> { type === 'dashboard' ? `Is there anything users of this dashboard should be aware of?` : - `Anything users should be aware of about this ${type}?` + `Anything users should be aware of about this ${type}?` } - </EditLabel> - <textarea - className={S.guideDetailEditorTextarea} + </EditLabel> + <textarea + className={S.guideDetailEditorTextarea} placeholder="Write something helpful here" {...formField.caveats} disabled={disabled} /> </div> - { type === 'metric' && + { type === 'metric' && <div className={cx('mb2', { 'disabled' : disabled })}> <EditLabel key="metricFieldsLabel"> Which 2-3 fields do you usually group this metric by? diff --git a/frontend/src/metabase/reference/containers/ReferenceApp.jsx b/frontend/src/metabase/reference/containers/ReferenceApp.jsx index 1b7de3034393e1f091b38d4b6b0373981c8d4fe7..f89d6b700e4d931c4d466fabb74049a8a085986e 100644 --- a/frontend/src/metabase/reference/containers/ReferenceApp.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceApp.jsx @@ -1,6 +1,5 @@ /* eslint "react/prop-types": "warn" */ import React, { Component, PropTypes } from 'react'; -import ReactDom from 'react-dom'; import { connect } from 'react-redux'; import Sidebar from 'metabase/components/Sidebar.jsx'; diff --git a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx index 908a1fe84e316d6c6251da5e1620d491fc263912..b5d43d664d6c7a0c585bf59b9e4fe373ba92883b 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx @@ -1,6 +1,5 @@ /* eslint "react/prop-types": "warn" */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import { connect } from "react-redux"; import { reduxForm } from "redux-form"; import { push } from "react-router-redux"; @@ -48,7 +47,7 @@ const mapStateToProps = (state, props) => { const entity = getData(state, props) || {}; const guide = getGuide(state, props); const fields = getFields(state, props); - + const initialValues = { important_fields: guide && guide.metric_important_fields && guide.metric_important_fields[entity.id] && @@ -308,7 +307,7 @@ export default class ReferenceEntity extends Component { .map(fieldId => metadataFields[fieldId]) .reduce((map, field) => ({ ...map, [field.id]: field }), {}) } - databaseId={table.db_id} + databaseId={table.db_id} metric={entity} title={ guide && guide.metric_important_fields[entity.id] ? "Other fields you can group this metric by" : diff --git a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx index d0b4fc965e1bbc374cf8e536353f1ace9896f390..9f821341533866af791d903eec787c720cec6106 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx @@ -1,6 +1,5 @@ /* eslint "react/prop-types": "warn" */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import { connect } from "react-redux"; import moment from "moment"; diff --git a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx index 1f665afdf358e709ae94e72134f7aea221f2cf1a..b680dba9f4a54aa3a360db4bd9ad12c881bd8e75 100644 --- a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx @@ -1,6 +1,5 @@ /* eslint "react/prop-types": "warn" */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import { connect } from "react-redux"; import { reduxForm } from "redux-form"; diff --git a/frontend/src/metabase/routes-internal.js b/frontend/src/metabase/routes-internal.js index 5ccc1f5e230f854508f86bc46fdc5dc83a047861..7250a151c1f39e4fd1fe8549c935ceb1a84563f7 100644 --- a/frontend/src/metabase/routes-internal.js +++ b/frontend/src/metabase/routes-internal.js @@ -3,6 +3,8 @@ import { Route } from "react-router"; import Icon from "metabase/components/Icon.jsx"; +import cx from "classnames"; + const SIZES = [12, 16]; const ListApp = () => @@ -94,10 +96,26 @@ class EmbedTestApp extends Component { } } +// eslint-disable-next-line import/no-commonjs +let colorStyles = require("!style!css?modules!postcss!metabase/css/core/colors.css"); + +const ColorsApp = () => + <div className="p2"> + {Object.entries(colorStyles).map(([name, className]) => + <div + className={cx(className, "rounded px1")} + style={{ paddingTop: "0.25em", paddingBottom: "0.25em", marginBottom: "0.25em" }} + > + {name} + </div> + )} + </div> + export default ( <Route> <Route path="list" component={ListApp} /> <Route path="icons" component={IconsApp} /> + <Route path="colors" component={ColorsApp} /> <Route path="embed/:type/:uuid" component={EmbedTestApp} /> </Route> ); diff --git a/frontend/src/metabase/selectors/metadata.js b/frontend/src/metabase/selectors/metadata.js new file mode 100644 index 0000000000000000000000000000000000000000..744a84b3c609fc1c7d02a192457de3eb15be5315 --- /dev/null +++ b/frontend/src/metabase/selectors/metadata.js @@ -0,0 +1,5 @@ + +export const getTables = (state) => state.metadata.tables; +export const getFields = (state) => state.metadata.fields; +export const getMetrics = (state) => state.metadata.metrics; +export const getDatabases = (state) => Object.values(state.metadata.databases); diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0e3563eb51714831c71312168f6b487cb245f326 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -0,0 +1,91 @@ +/* @flow */ + +import React, { Component, PropTypes } from "react"; + +import Button from "metabase/components/Button"; +import Popover from "metabase/components/Popover"; + +import type { ClickObject, ClickAction } from "metabase/meta/types/Visualization"; +import type { Card } from "metabase/meta/types/Card"; + +type Props = { + clicked: ClickObject, + clickActions: ?ClickAction[], + onChangeCardAndRun: (card: ?Card) => void, + onClose: () => void +}; + +type State = { + popoverIndex: ?number; +} + +export default class ChartClickActions extends Component<*, Props, State> { + state: State = { + popoverIndex: null + }; + + close = () => { + this.setState({ popoverIndex: null }); + if (this.props.onClose) { + this.props.onClose(); + } + } + + render() { + const { clicked, clickActions, onChangeCardAndRun } = this.props; + + if (!clicked || !clickActions || clickActions.length === 0) { + return null; + } + + let { popoverIndex } = this.state; + if (clickActions.length === 1 && clickActions[0].popover && clickActions[0].default) { + popoverIndex = 0; + } + + let popover; + if (popoverIndex != null && clickActions[popoverIndex].popover) { + const PopoverContent = clickActions[popoverIndex].popover; + popover = ( + <PopoverContent + onChangeCardAndRun={onChangeCardAndRun} + onClose={this.close} + /> + ); + } + + return ( + <Popover + target={clicked.element} + targetEvent={clicked.event} + onClose={this.close} + verticalAttachments={["bottom", "top"]} + sizeToFit + > + { popover ? + popover + : + <div className="px1 pt1 flex flex-column"> + { clickActions.map((action, index) => + <Button + key={index} + className="mb1" + medium + onClick={() => { + if (action.popover) { + this.setState({ popoverIndex: index }); + } else if (action.card) { + onChangeCardAndRun(action.card()); + this.close(); + } + }} + > + {action.title} + </Button> + )} + </div> + } + </Popover> + ); + } +} diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx index 139d7b1af60213105164d7500b4412a4ba07f8fb..501b403030d05f48af9fe7fc1b59bb937f742384 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx @@ -19,7 +19,7 @@ export default class ChartTooltip extends Component { }; componentWillReceiveProps({ hovered }) { - if (hovered && !Array.isArray(hovered.data)) { + if (hovered && hovered.data && !Array.isArray(hovered.data)) { console.warn("hovered.data should be an array of { key, value, col }", hovered.data); } } diff --git a/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx b/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx index fa659407d2129637a91c12d8da40c0ceb7305f0e..9218edfcb3009c2e5de766cbaa5f26a76b66ecfb 100644 --- a/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx +++ b/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import styles from "./ChartWithLegend.css"; import LegendVertical from "./LegendVertical.jsx"; diff --git a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx index 58e940631cb0ff861eebb44ca2c0d732fedaab54..d390fa5d61203b5a6877e1c46ec9e2596bac3563 100644 --- a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx +++ b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import LoadingSpinner from "metabase/components/LoadingSpinner.jsx"; @@ -10,7 +9,6 @@ import MetabaseSettings from "metabase/lib/settings"; import { formatNumber } from "metabase/lib/formatting"; import ChartWithLegend from "./ChartWithLegend.jsx"; -import ChartTooltip from "./ChartTooltip.jsx"; import LegacyChoropleth from "./LegacyChoropleth.jsx"; import LeafletChoropleth from "./LeafletChoropleth.jsx"; @@ -120,7 +118,7 @@ export default class ChoroplethMap extends Component { ); } - const { series, className, gridSize, hovered, onHoverChange, settings } = this.props; + const { series, className, gridSize, hovered, onHoverChange, onVisualizationClick, settings } = this.props; let { geoJson, minimalBounds } = this.state; // special case builtin maps to use legacy choropleth map @@ -154,13 +152,30 @@ export default class ChoroplethMap extends Component { const getFeatureKey = (feature) => String(feature.properties[keyProperty]).toLowerCase(); const getFeatureValue = (feature) => valuesMap[getFeatureKey(feature)]; + const heatMapColors = HEAT_MAP_COLORS.slice(0, Math.min(HEAT_MAP_COLORS.length, rows.length)) + const onHoverFeature = (hover) => { onHoverChange && onHoverChange(hover && { - index: HEAT_MAP_COLORS.indexOf(getColor(hover.feature)), + index: heatMapColors.indexOf(getColor(hover.feature)), event: hover.event, data: { key: getFeatureName(hover.feature), value: getFeatureValue(hover.feature) } }) } + const onClickFeature = (click) => { + const featureKey = getFeatureKey(click.feature); + const row = _.find(rows, row => getRowKey(row) === featureKey); + if (onVisualizationClick && row !== undefined) { + onVisualizationClick({ + value: row[metricIndex], + column: cols[metricIndex], + dimensions: [{ + value: row[dimensionIndex], + column: cols[dimensionIndex] + }], + event: click.event + }); + } + } const valuesMap = {}; const domain = [] @@ -169,15 +184,15 @@ export default class ChoroplethMap extends Component { domain.push(getRowValue(row)); } - const groups = ss.ckmeans(domain, HEAT_MAP_COLORS.length); + const groups = ss.ckmeans(domain, heatMapColors.length); - var colorScale = d3.scale.quantile().domain(groups.map((cluster) => cluster[0])).range(HEAT_MAP_COLORS); + var colorScale = d3.scale.quantile().domain(groups.map((cluster) => cluster[0])).range(heatMapColors); - let legendColors = HEAT_MAP_COLORS.slice(); - let legendTitles = HEAT_MAP_COLORS.map((color, index) => { + let legendColors = heatMapColors.slice(); + let legendTitles = heatMapColors.map((color, index) => { const min = groups[index][0]; const max = groups[index].slice(-1)[0]; - return index === HEAT_MAP_COLORS.length - 1 ? + return index === heatMapColors.length - 1 ? formatNumber(min) + " +" : formatNumber(min) + " - " + formatNumber(max) }); @@ -213,6 +228,7 @@ export default class ChoroplethMap extends Component { geoJson={geoJson} getColor={getColor} onHoverFeature={onHoverFeature} + onClickFeature={onClickFeature} projection={projection} /> : @@ -221,10 +237,10 @@ export default class ChoroplethMap extends Component { geoJson={geoJson} getColor={getColor} onHoverFeature={onHoverFeature} + onClickFeature={onClickFeature} minimalBounds={minimalBounds} /> } - <ChartTooltip series={series} hovered={hovered} /> </ChartWithLegend> ); } diff --git a/frontend/src/metabase/visualizations/components/FunnelBar.jsx b/frontend/src/metabase/visualizations/components/FunnelBar.jsx index 06f85d90983b55e9c39b9bd346324dfc67075756..79454994387531948a582ba368aa0fb591bb7687 100644 --- a/frontend/src/metabase/visualizations/components/FunnelBar.jsx +++ b/frontend/src/metabase/visualizations/components/FunnelBar.jsx @@ -1,14 +1,13 @@ /* @flow */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import BarChart from "metabase/visualizations/visualizations/BarChart.jsx"; import { getSettings } from "metabase/visualizations/lib/settings"; import { assocIn } from "icepick"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; export default class BarFunnel extends Component<*, VisualizationProps, *> { render() { diff --git a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx index 6023ccd3e0ff9d2155d5c6c610bda9ee1ea69a26..2f793f8d1949ac20073e631854455fb7cb360d85 100644 --- a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx +++ b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx @@ -1,12 +1,10 @@ /* @flow */ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import cx from "classnames"; import styles from "./FunnelNormal.css"; -import ChartTooltip from "metabase/visualizations/components/ChartTooltip.jsx"; import Ellipsified from "metabase/components/Ellipsified.jsx"; import { formatValue } from "metabase/lib/formatting"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; @@ -15,7 +13,7 @@ import { normal } from "metabase/lib/colors"; const DEFAULT_COLORS = Object.values(normal); -import type { VisualizationProps, HoverData } from "metabase/visualizations"; +import type { VisualizationProps, HoverObject, ClickObject } from "metabase/meta/types/Visualization"; type StepInfo = { value: number, @@ -25,17 +23,18 @@ type StepInfo = { endBottom: number, endTop: number }, - tooltip?: HoverData + hovered?: HoverObject, + clicked?: ClickObject, }; export default class Funnel extends Component<*, VisualizationProps, *> { render() { - const { className, series, gridSize, hovered, onHoverChange } = this.props; + const { className, series, gridSize, hovered, onHoverChange, onVisualizationClick, visualizationIsClickable } = this.props; const dimensionIndex = 0; const metricIndex = 1; const cols = series[0].data.cols; - // $FlowFixMe: doesn't like intersection type + // $FlowFixMe const rows = series.map(s => s.data.rows[0]); const funnelSmallSize = gridSize && (gridSize.width < 7 || gridSize.height <= 5); @@ -43,24 +42,6 @@ export default class Funnel extends Component<*, VisualizationProps, *> { const formatDimension = (dimension, jsx = true) => formatValue(dimension, { column: cols[dimensionIndex], jsx, majorWidth: 0 }) const formatMetric = (metric, jsx = true) => formatValue(metric, { column: cols[metricIndex], jsx, majorWidth: 0 , comma: true}) const formatPercent = (percent) => `${(100 * percent).toFixed(2)} %` - const calculateGraphStyle = (info, currentStep, stepsNumber, hovered) => { - var sizeConverter = 100; - - let styles = { - WebkitClipPath: `polygon(0 ${info.graph.startBottom * sizeConverter}%, 0 ${info.graph.startTop * sizeConverter}%, 100% ${info.graph.endTop * sizeConverter}%, 100% ${info.graph.endBottom * sizeConverter}%)`, - MozClipPath: `polygon(0 ${info.graph.startBottom * sizeConverter}%, 0 ${info.graph.startTop * sizeConverter}%, 100% ${info.graph.endTop * sizeConverter}%, 100% ${info.graph.endBottom * sizeConverter}%)`, - msClipPath: `polygon(0 ${info.graph.startBottom * sizeConverter}%, 0 ${info.graph.startTop * sizeConverter}%, 100% ${info.graph.endTop * sizeConverter}%, 100% ${info.graph.endBottom * sizeConverter}%)`, - ClipPath: `polygon(0 ${info.graph.startBottom * sizeConverter}%, 0 ${info.graph.startTop * sizeConverter}%, 100% ${info.graph.endTop * sizeConverter}%, 100% ${info.graph.endBottom * sizeConverter}%)`, - backgroundColor: DEFAULT_COLORS[0], - opacity: 1 - (currentStep * (0.9 / stepsNumber)), - }; - - if (hovered) { - styles.opacity = hovered.index !== currentStep ? 0.3 : 1; - } - - return styles - } // Initial infos (required for step calculation) var infos: StepInfo[] = [{ @@ -80,26 +61,40 @@ export default class Funnel extends Component<*, VisualizationProps, *> { infos[rowIndex + 1] = { value: row[metricIndex], + graph: { startBottom: infos[rowIndex].graph.endBottom, startTop: infos[rowIndex].graph.endTop, endTop: 0.5 + ((remaining / infos[0].value) / 2), endBottom: 0.5 - ((remaining / infos[0].value) / 2), }, - tooltip: [ - { - key: 'Step', - value: formatDimension(row[dimensionIndex]), - }, - { - key: getFriendlyName(cols[metricIndex]), - value: formatMetric(row[metricIndex]), - }, - { - key: 'Retained', - value: formatPercent(row[metricIndex] / infos[0].value), - }, - ], + + hovered: { + index: rowIndex, + data: [ + { + key: 'Step', + value: formatDimension(row[dimensionIndex]), + }, + { + key: getFriendlyName(cols[metricIndex]), + value: formatMetric(row[metricIndex]), + }, + { + key: 'Retained', + value: formatPercent(row[metricIndex] / infos[0].value), + }, + ] + }, + + clicked: { + value: row[metricIndex], + column: cols[metricIndex], + dimensions: [{ + value: row[dimensionIndex], + column: cols[dimensionIndex], + }] + } }; }); @@ -108,6 +103,8 @@ export default class Funnel extends Component<*, VisualizationProps, *> { let initial = infos[0]; + const isClickable = visualizationIsClickable(infos[0].clicked); + return ( <div className={cx(className, styles.Funnel, 'flex', { [styles.Small]: funnelSmallSize, @@ -129,24 +126,74 @@ export default class Funnel extends Component<*, VisualizationProps, *> { {infos.slice(1).map((info, index) => <div key={index} className={cx(styles.FunnelStep, 'flex flex-column')}> <Ellipsified className={styles.Head}>{formatDimension(rows[index + 1][dimensionIndex])}</Ellipsified> - <div - className={styles.Graph} - onMouseMove={(event) => onHoverChange({ - index: index, - event: event.nativeEvent, - data: info.tooltip, - })} - onMouseLeave={() => onHoverChange(null)} - style={calculateGraphStyle(info, index, infos.length + 1, hovered)}> </div> + <GraphSection + className={cx({ "cursor-pointer": isClickable })} + index={index} + info={info} + infos={infos} + hovered={hovered} + onHoverChange={onHoverChange} + onVisualizationClick={isClickable ? onVisualizationClick : null} + /> <div className={styles.Infos}> <div className={styles.Title}>{formatPercent(info.value / initial.value)}</div> <div className={styles.Subtitle}>{formatMetric(rows[index + 1][metricIndex])}</div> </div> </div> )} - {/* Display tooltips following mouse */} - <ChartTooltip series={series} hovered={hovered} /> </div> ); } } +const GraphSection = ( + { + index, + info, + infos, + hovered, + onHoverChange, + onVisualizationClick, + className, + }: { + className?: string, + index: number, + info: StepInfo, + infos: StepInfo[], + hovered: ?HoverObject, + onVisualizationClick: ?((clicked: ?ClickObject) => void), + onHoverChange: (hovered: ?HoverObject) => void + } +) => { + return ( + <svg + className={cx(className, styles.Graph)} + onMouseMove={e => { + if (onHoverChange && info.hovered) { + onHoverChange({ + ...info.hovered, + event: e.nativeEvent + }) + } + }} + onMouseLeave={() => onHoverChange && onHoverChange(null)} + onClick={e => { + if (onVisualizationClick && info.clicked) { + onVisualizationClick({ + ...info.clicked, + event: e.nativeEvent + }) + } + }} + viewBox="0 0 1 1" + preserveAspectRatio="none" + > + <polygon + opacity={1 - index * (0.9 / (infos.length + 1))} + fill={DEFAULT_COLORS[0]} + points={ + `0 ${info.graph.startBottom}, 0 ${info.graph.startTop}, 1 ${info.graph.endTop}, 1 ${info.graph.endBottom}` + } + /> + </svg> + ); +}; diff --git a/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx b/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx index 6311f2d52e629754f96834c4643ef36842c788ed..b958b5b4cd7890e6cb7bbd17cea5b557622fecf9 100644 --- a/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx @@ -10,7 +10,14 @@ import L from "leaflet"; import { computeMinimalBounds } from "metabase/visualizations/lib/mapping"; -const LeafletChoropleth = ({ series, geoJson, minimalBounds = computeMinimalBounds(geoJson.features), getColor = () => normal.blue, onHoverFeature = () => {}, }) => +const LeafletChoropleth = ({ + series, + geoJson, + minimalBounds = computeMinimalBounds(geoJson.features), + getColor = () => normal.blue, + onHoverFeature = () => {}, + onClickFeature = () => {}, +}) => <CardRenderer series={series} className="spread" @@ -56,7 +63,13 @@ const LeafletChoropleth = ({ series, geoJson, minimalBounds = computeMinimalBoun }, mouseout: (e) => { onHoverFeature(null) - } + }, + click: (e) => { + onClickFeature({ + feature: feature, + event: e.originalEvent + }) + }, }); } diff --git a/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx b/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx index 3566f85b23bbecb59543b1182fc42ccf6cf610d0..da7b688e340f2a4792ad9d6143b5d620750030f3 100644 --- a/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import LeafletMap from "./LeafletMap.jsx"; import L from "leaflet"; diff --git a/frontend/src/metabase/visualizations/components/LegacyChoropleth.jsx b/frontend/src/metabase/visualizations/components/LegacyChoropleth.jsx index 89bfee6dc9695e010cb052856203c867037e4454..8059a0b659cdf9f63ca053f339304d5777e0177d 100644 --- a/frontend/src/metabase/visualizations/components/LegacyChoropleth.jsx +++ b/frontend/src/metabase/visualizations/components/LegacyChoropleth.jsx @@ -3,7 +3,7 @@ import React, { Component, PropTypes } from "react"; import { isSameSeries } from "metabase/visualizations/lib/utils"; import d3 from "d3"; -const LegacyChoropleth = ({ series, geoJson, projection, getColor, onHoverFeature }) => { +const LegacyChoropleth = ({ series, geoJson, projection, getColor, onHoverFeature, onClickFeature }) => { let geo = d3.geo.path() .projection(projection); @@ -27,6 +27,10 @@ const LegacyChoropleth = ({ series, geoJson, projection, getColor, onHoverFeatur event: e.nativeEvent })} onMouseLeave={() => onHoverFeature(null)} + onClick={(e) => onClickFeature({ + feature: feature, + event: e.nativeEvent + })} /> )} </svg> diff --git a/frontend/src/metabase/visualizations/components/LegendHeader.jsx b/frontend/src/metabase/visualizations/components/LegendHeader.jsx index 3dc25dd0c7842e7a9f8b28e8bde37d3f6100d72c..3574ac8732835fdb54eeb4788714f09b12e5fef5 100644 --- a/frontend/src/metabase/visualizations/components/LegendHeader.jsx +++ b/frontend/src/metabase/visualizations/components/LegendHeader.jsx @@ -33,7 +33,8 @@ export default class LegendHeader extends Component { static defaultProps = { series: [], - settings: {} + settings: {}, + visualizationIsClickable: () => false }; componentDidMount() { @@ -48,12 +49,15 @@ export default class LegendHeader extends Component { } render() { - const { series, hovered, onRemoveSeries, actionButtons, onHoverChange, linkToCard, settings, description } = this.props; + const { series, hovered, onRemoveSeries, actionButtons, onHoverChange, linkToCard, settings, description, onVisualizationClick, visualizationIsClickable } = this.props; const showDots = series.length > 1; const isNarrow = this.state.width < 150; const showTitles = !showDots || !isNarrow; let colors = settings["graph.colors"] || DEFAULT_COLORS; + + const isClickable = series.length > 0 && series[0].clicked && visualizationIsClickable(series[0].clicked); + return ( <div className={cx(styles.LegendHeader, "Card-title mx1 flex flex-no-shrink flex-row align-center")}> { series.map((s, index) => [ @@ -68,7 +72,10 @@ export default class LegendHeader extends Component { isMuted={hovered && hovered.index != null && index !== hovered.index} onMouseEnter={() => onHoverChange && onHoverChange({ index })} onMouseLeave={() => onHoverChange && onHoverChange(null) } - />, + onClick={isClickable && ((e) => + onVisualizationClick({ ...s.clicked, element: e.currentTarget }) + )} + />, onRemoveSeries && index > 0 && <Icon name="close" diff --git a/frontend/src/metabase/visualizations/components/LegendItem.jsx b/frontend/src/metabase/visualizations/components/LegendItem.jsx index d62be8776af3340b953571d18170a183e9ea82e1..9a594e4317aee5a9370cfdea9afccbbe16dae67d 100644 --- a/frontend/src/metabase/visualizations/components/LegendItem.jsx +++ b/frontend/src/metabase/visualizations/components/LegendItem.jsx @@ -1,5 +1,4 @@ import React, { Component, PropTypes } from "react"; -import ReactDOM from "react-dom"; import Icon from "metabase/components/Icon.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; @@ -27,14 +26,19 @@ export default class LegendItem extends Component { }; render() { - const { title, href, color, showDot, showTitle, isMuted, showTooltip, showDotTooltip, onMouseEnter, onMouseLeave, className, description } = this.props; + const { title, href, color, showDot, showTitle, isMuted, showTooltip, showDotTooltip, onMouseEnter, onMouseLeave, className, description, onClick } = this.props; return ( <LegendLink href={href} - className={cx(className, "LegendItem", "no-decoration flex align-center fullscreen-normal-text fullscreen-night-text", { mr1: showTitle, muted: isMuted })} + className={cx(className, "LegendItem", "no-decoration flex align-center fullscreen-normal-text fullscreen-night-text", { + mr1: showTitle, + muted: isMuted, + "cursor-pointer": onClick + })} style={{ overflowX: "hidden", flex: "0 1 auto" }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onClick={onClick} > { showDot && <Tooltip tooltip={title} isEnabled={showTooltip && showDotTooltip}> diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index e6e4a4e6970c76a2e008b10fec8251e95ea056a0..e2716e6f90dd953c56e7bee892c527d16aa3366b 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -4,7 +4,6 @@ import React, { Component, PropTypes } from "react"; import CardRenderer from "./CardRenderer.jsx"; import LegendHeader from "./LegendHeader.jsx"; -import ChartTooltip from "./ChartTooltip.jsx"; import "./LineAreaBarChart.css"; @@ -41,7 +40,7 @@ for (let i = 0; i < MAX_SERIES; i++) { addCSSRule(`.LineAreaBarChart.mute-${i} svg:not(.stacked) .row`, MUTE_STYLE); } -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; export default class LineAreaBarChart extends Component<*, VisualizationProps, *> { static identifier: string; @@ -176,7 +175,7 @@ export default class LineAreaBarChart extends Component<*, VisualizationProps, * } render() { - const { series, hovered, showTitle, actionButtons, linkToCard } = this.props; + const { series, hovered, showTitle, actionButtons, linkToCard, onVisualizationClick, visualizationIsClickable } = this.props; const settings = this.getSettings(); @@ -216,6 +215,8 @@ export default class LineAreaBarChart extends Component<*, VisualizationProps, * onHoverChange={this.props.onHoverChange} actionButtons={!titleHeaderSeries ? actionButtons : null} linkToCard={linkToCard} + onVisualizationClick={onVisualizationClick} + visualizationIsClickable={visualizationIsClickable} /> : null } <CardRenderer @@ -226,7 +227,6 @@ export default class LineAreaBarChart extends Component<*, VisualizationProps, * maxSeries={MAX_SERIES} renderer={this.constructor.renderer} /> - <ChartTooltip series={series} hovered={hovered} /> </div> ); } @@ -296,12 +296,18 @@ function transformSingleSeries(s, series, seriesIndex) { ].filter(n => n).join(": "), _transformed: true, _breakoutValue: breakoutValue, - _breakoutColumn: cols[seriesColumnIndex] + _breakoutColumn: cols[seriesColumnIndex], }, data: { rows: breakoutRowsByValue.get(breakoutValue), cols: rowColumnIndexes.map(i => cols[i]), _rawCols: cols + }, + clicked: { + dimensions: [{ + value: breakoutValue, + column: cols[seriesColumnIndex] + }] } })); } else { diff --git a/frontend/src/metabase/visualizations/components/PinMap.jsx b/frontend/src/metabase/visualizations/components/PinMap.jsx index ff434eefc77991d348e5bb278e58b8357e11a03d..596864c978aec3be5776947a4373acafef1068cc 100644 --- a/frontend/src/metabase/visualizations/components/PinMap.jsx +++ b/frontend/src/metabase/visualizations/components/PinMap.jsx @@ -13,7 +13,7 @@ import cx from "classnames"; import L from "leaflet"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; type Props = VisualizationProps; diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 43884f285a214e58c345c91054316ec47be79800..4e33e5d28b67367582839eca374a08741290b841 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -8,9 +8,7 @@ import "./TableInteractive.css"; import Icon from "metabase/components/Icon.jsx"; import Value from "metabase/components/Value.jsx"; -import QuickFilterPopover from "metabase/query_builder/components/QuickFilterPopover.jsx"; -import MetabaseAnalytics from "metabase/lib/analytics"; import { capitalize } from "metabase/lib/formatting"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; @@ -26,20 +24,15 @@ const ROW_HEIGHT = 35; const MIN_COLUMN_WIDTH = ROW_HEIGHT; const RESIZE_HANDLE_WIDTH = 5; -import type { Column } from "metabase/meta/types/Dataset"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; type Props = VisualizationProps & { width: number, height: number, sort: any, isPivoted: boolean, - cellClickedFn: (number, number) => void, - cellIsClickableFn: (number, number) => boolean, - setSortFn: (/* TODO */) => void, } type State = { - popover: ?{ rowIndex: number, columnIndex: number }, columnWidths: number[], contentWidths: ?number[] } @@ -69,7 +62,6 @@ export default class TableInteractive extends Component<*, Props, State> { super(props); this.state = { - popover: null, columnWidths: [], contentWidths: null }; @@ -79,16 +71,11 @@ export default class TableInteractive extends Component<*, Props, State> { static propTypes = { data: PropTypes.object.isRequired, isPivoted: PropTypes.bool.isRequired, - sort: PropTypes.array, - setSortFn: PropTypes.func, - cellIsClickableFn: PropTypes.func.isRequired, - cellClickedFn: PropTypes.func.isRequired + sort: PropTypes.array }; static defaultProps = { isPivoted: false, - cellIsClickableFn: () => false, - cellClickedFn: () => {} }; componentWillMount() { @@ -137,7 +124,7 @@ export default class TableInteractive extends Component<*, Props, State> { contentWidths: null }); this.columnHasResized = {}; - this.props.onUpdateVisualizationSettings({ "table.column_widths": [] }); + this.props.onUpdateVisualizationSettings({ "table.column_widths": undefined }); } _measure() { @@ -154,6 +141,8 @@ export default class TableInteractive extends Component<*, Props, State> { return contentWidths[index] + 1; // + 1 to make sure it doen't wrap? } else if (this.state.columnWidths[index]) { return this.state.columnWidths[index]; + } else { + return 0; } } else { return contentWidths[index] + 1; @@ -221,82 +210,61 @@ export default class TableInteractive extends Component<*, Props, State> { setTimeout(() => this.recomputeGridSize(), 1); } - isSortable() { - return (this.props.setSortFn !== undefined); - } - - setSort(column: Column) { - // lets completely delegate this to someone else up the stack :) - this.props.setSortFn(column); - MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'table column'); - } - - cellClicked(rowIndex: number, columnIndex: number) { - this.props.cellClickedFn(rowIndex, columnIndex); - } - - popoverFilterClicked(rowIndex: number, columnIndex: number, operator: string) { - this.props.cellClickedFn(rowIndex, columnIndex, operator); - this.setState({ popover: null }); - } - - showPopover(rowIndex: number, columnIndex: number) { - this.setState({ - popover: { - rowIndex: rowIndex, - columnIndex: columnIndex - } - }); - } - - onClosePopover = () => { - this.setState({ popover: null }); - } - cellRenderer = ({ key, style, rowIndex, columnIndex }: CellRendererProps) => { - const { data: { cols, rows }} = this.props; + const { isPivoted, onVisualizationClick, visualizationIsClickable } = this.props; + // $FlowFixMe: not sure why flow has a problem with this + const { rows, cols } = this.props.data; + const column = cols[columnIndex]; - const cellData = rows[rowIndex][columnIndex]; - if (this.props.cellIsClickableFn(rowIndex, columnIndex)) { - return ( - <div - key={key} style={style} - className={cx("TableInteractive-cellWrapper cellData", { - "TableInteractive-cellWrapper--firstColumn": columnIndex === 0 - })} - onClick={this.cellClicked.bind(this, rowIndex, columnIndex)} - > - <Value className="link" value={cellData} column={column} onResize={this.onCellResize.bind(this, columnIndex)} /> - </div> - ); + const row = rows[rowIndex]; + const value = row[columnIndex]; + + let clicked; + if (isPivoted) { + // if it's a pivot table, the first column is + if (columnIndex === 0) { + clicked = row._dimension; + } else { + clicked = { + value, + column, + dimensions: [row._dimension, column._dimension] + }; + } + } else if (column.source === "aggregation") { + clicked = { + value, + column, + dimensions: cols + .map((column, index) => ({ value: row[index], column })) + .filter(dimension => dimension.column.source === "breakout") + }; } else { - const { popover } = this.state; - const isFilterable = column.source !== "aggregation"; - return ( - <div - key={key} style={style} - className={cx("TableInteractive-cellWrapper cellData", { - "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, - "cursor-pointer": isFilterable - })} - onClick={isFilterable && this.showPopover.bind(this, rowIndex, columnIndex)} - > - <Value value={cellData} column={column} onResize={this.onCellResize.bind(this, columnIndex)} /> - { popover && popover.rowIndex === rowIndex && popover.columnIndex === columnIndex && - <QuickFilterPopover - column={cols[popover.columnIndex]} - onFilter={this.popoverFilterClicked.bind(this, rowIndex, columnIndex)} - onClose={this.onClosePopover} - /> - } - </div> - ); + clicked = { value, column }; } + + const isClickable = onVisualizationClick && visualizationIsClickable(clicked); + + return ( + <div + key={key} style={style} + className={cx("TableInteractive-cellWrapper cellData", { + "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, + "cursor-pointer": isClickable + })} + onClick={isClickable && ((e) => { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + })} + > + <Value className="link" value={value} column={column} onResize={this.onCellResize.bind(this, columnIndex)} /> + </div> + ); } tableHeaderRenderer = ({ key, style, columnIndex }: CellRendererProps) => { - const { sort, data: { cols }} = this.props; - const isSortable = this.isSortable(); + const { sort, isPivoted, onVisualizationClick, visualizationIsClickable } = this.props; + // $FlowFixMe: not sure why flow has a problem with this + const { cols } = this.props.data; const column = cols[columnIndex]; let columnTitle = getFriendlyName(column); @@ -307,6 +275,19 @@ export default class TableInteractive extends Component<*, Props, State> { columnTitle = "Unset"; } + let clicked; + if (isPivoted) { + // if it's a pivot table, the first column is + if (columnIndex >= 0) { + clicked = column._dimension; + } + } else { + clicked = { column }; + } + + const isClickable = onVisualizationClick && visualizationIsClickable(clicked); + const isSortable = isClickable && column.source; + return ( <div key={key} @@ -317,8 +298,10 @@ export default class TableInteractive extends Component<*, Props, State> { })} > <div - className={cx("cellData", { "cursor-pointer": isSortable })} - onClick={isSortable && this.setSort.bind(this, column)} + className={cx("cellData", { "cursor-pointer": isClickable })} + onClick={isClickable && ((e) => { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + })} > {columnTitle} {isSortable && diff --git a/frontend/src/metabase/visualizations/components/TableSimple.jsx b/frontend/src/metabase/visualizations/components/TableSimple.jsx index 3da9eedcc081d016fff9154b1be777b800fdd7f1..d2f5081be36f05185dd1b424540606134d3bdc47 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple.jsx @@ -14,7 +14,7 @@ import { getFriendlyName } from "metabase/visualizations/lib/utils"; import cx from "classnames"; import _ from "underscore"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; type Props = VisualizationProps & { height: number, diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 9fa15c56f6292533079a933025a7a8b30127a6a1..404daf44cd669f772dc44af176485e21fee053fe 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -4,6 +4,8 @@ import React, { Component, PropTypes, Element } from "react"; import ExplicitSize from "metabase/components/ExplicitSize.jsx"; import LegendHeader from "metabase/visualizations/components/LegendHeader.jsx"; +import ChartTooltip from "metabase/visualizations/components/ChartTooltip.jsx"; +import ChartClickActions from "metabase/visualizations/components/ChartClickActions.jsx"; import LoadingSpinner from "metabase/components/LoadingSpinner.jsx"; import Icon from "metabase/components/Icon.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; @@ -15,6 +17,8 @@ import { getSettings } from "metabase/visualizations/lib/settings"; import { isSameSeries } from "metabase/visualizations/lib/utils"; import Utils from "metabase/lib/utils"; +import { getModeDrills } from "metabase/qb/lib/modes" + import { MinRowsError, ChartSettingsError } from "metabase/visualizations/lib/errors"; import { assoc, getIn, setIn } from "icepick"; @@ -24,8 +28,9 @@ import cx from "classnames"; export const ERROR_MESSAGE_GENERIC = "There was a problem displaying this chart."; export const ERROR_MESSAGE_PERMISSION = "Sorry, you don't have permission to see this card." -import type { VisualizationSettings } from "metabase/meta/types/Card"; -import type { HoverObject, Series } from "metabase/visualizations"; +import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; +import type { HoverObject, ClickObject, Series, QueryMode } from "metabase/meta/types/Visualization"; +import type { TableMetadata } from "metabase/meta/types/Metadata"; type Props = { series: Series, @@ -53,11 +58,15 @@ type Props = { // settings overrides from settings panel settings: VisualizationSettings, + // for click actions + mode?: QueryMode, + tableMetadata: TableMetadata, + onChangeCardAndRun: (card: Card) => void, + // used for showing content in place of visualization, e.x. dashcard filter mapping replacementContent: Element<any>, // used by TableInteractive - setSortFn: (any) => void, cellIsClickableFn: (number, number) => boolean, cellClickedFn: (number, number) => void, @@ -81,6 +90,8 @@ type State = { }), hovered: ?HoverObject, + clicked: ?ClickObject, + error: ?Error, warnings: string[], yAxisSplit: ?number[][], @@ -96,6 +107,7 @@ export default class Visualization extends Component<*, Props, State> { this.state = { hovered: null, + clicked: null, error: null, warnings: [], yAxisSplit: null, @@ -153,6 +165,7 @@ export default class Visualization extends Component<*, Props, State> { transform(newProps) { this.setState({ hovered: null, + clicked: null, error: null, warnings: [], yAxisSplit: null, @@ -160,7 +173,7 @@ export default class Visualization extends Component<*, Props, State> { }); } - onHoverChange = (hovered) => { + handleHoverChange = (hovered) => { const { yAxisSplit } = this.state; if (hovered) { // if we have Y axis split info then find the Y axis index (0 = left, 1 = right) @@ -172,6 +185,37 @@ export default class Visualization extends Component<*, Props, State> { this.setState({ hovered }); } + getClickActions(clicked: ?ClickObject) { + const { mode, series: [{ card }], tableMetadata } = this.props; + return getModeDrills(mode, card, tableMetadata, clicked); + } + + visualizationIsClickable = (clicked: ClickObject) => { + const { onChangeCardAndRun } = this.props; + if (!onChangeCardAndRun) { + return false; + } + try { + return this.getClickActions(clicked).length > 0; + } catch (e) { + return false; + } + } + + handleVisualizationClick = (clicked: ClickObject) => { + // needs to be delayed so we don't clear it when switching from one drill through to another + setTimeout(() => { + const { onChangeCardAndRun } = this.props; + let clickActions = this.getClickActions(clicked); + // if there's a single drill action (without a popover) execute it immediately + if (clickActions.length === 1 && clickActions[0].default && clickActions[0].card) { + onChangeCardAndRun(clickActions[0].card()); + } else { + this.setState({ clicked }); + } + }, 100) + } + onRender = ({ yAxisSplit, warnings = [] } = {}) => { this.setState({ yAxisSplit, warnings }); } @@ -185,6 +229,13 @@ export default class Visualization extends Component<*, Props, State> { const { series, CardVisualization } = this.state; const small = width < 330; + let { hovered, clicked } = this.state; + + const clickActions = this.getClickActions(clicked); + if (clickActions.length > 0) { + hovered = null; + } + let error = this.props.error || this.state.error; let loading = !(series && series.length > 0 && _.every(series, (s) => s.data)); let noResults = false; @@ -244,7 +295,7 @@ export default class Visualization extends Component<*, Props, State> { return ( <div className={cx(className, "flex flex-column")}> - { showTitle && (settings["card.title"] || extra) && (loading || error || !(CardVisualization && CardVisualization.noHeader)) || replacementContent ? + { showTitle && (settings["card.title"] || extra) && (loading || error || noResults || !(CardVisualization && CardVisualization.noHeader)) || replacementContent ? <div className="p1 flex-no-shrink"> <LegendHeader series={ @@ -320,14 +371,26 @@ export default class Visualization extends Component<*, Props, State> { card={series[0].card} // convienence for single-series visualizations // $FlowFixMe data={series[0].data} // convienence for single-series visualizations - hovered={this.state.hovered} - onHoverChange={this.onHoverChange} + hovered={hovered} + onHoverChange={this.handleHoverChange} + onVisualizationClick={this.handleVisualizationClick} + visualizationIsClickable={this.visualizationIsClickable} onRenderError={this.onRenderError} onRender={this.onRender} gridSize={gridSize} linkToCard={linkToCard} /> } + <ChartTooltip + series={series} + hovered={hovered} + /> + <ChartClickActions + clicked={clicked} + clickActions={clickActions} + onChangeCardAndRun={this.props.onChangeCardAndRun} + onClose={() => this.setState({ clicked: null })} + /> </div> ); } diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index 9e25849f0ae94e9cf60fc3f0eaa20fcc2405e20d..2e9780978050ce64be8c86797c60c999dcffb99b 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -16,45 +16,7 @@ import Funnel from "./visualizations/Funnel.jsx"; import _ from "underscore"; -import type { DatasetData, Column } from "metabase/meta/types/Dataset"; -import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; - -export type HoverData = Array<{ key: string, value: any, col?: Column }>; - -export type HoverObject = { - index?: number, - axisIndex?: number, - data?: HoverData -} - -// type Visualization = Component<*, VisualizationProps, *>; - -// $FlowFixMe -export type Series = { card: Card, data: DatasetData }[] & { _raw: Series } - -export type VisualizationProps = { - series: Series, - card: Card, - data: DatasetData, - settings: VisualizationSettings, - - className?: string, - gridSize: ?{ - width: number, - height: number - }, - - showTitle: boolean, - isDashboard: boolean, - isEditing: boolean, - actionButtons: Node, - linkToCard?: bool, - - hovered: ?HoverObject, - onHoverChange: (?HoverObject) => void, - - onUpdateVisualizationSettings: ({ [key: string]: any }) => void -} +import type { Series } from "metabase/meta/types/Visualization"; const visualizations = new Map(); const aliases = new Map(); diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index 76ab119a1acdc3c7f86c56fdf3c82bed55850fab..a2ecd311f47ef29c49de0db216ad4f33c9701925 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -293,64 +293,119 @@ function applyChartYAxis(chart, settings, series, yExtent, axisName) { } } -function applyChartTooltips(chart, series, isStacked, onHoverChange) { +function applyChartTooltips(chart, series, isStacked, onHoverChange, onVisualizationClick) { let [{ data: { cols } }] = series; chart.on("renderlet.tooltips", function(chart) { - chart.selectAll(".bar, .dot, .area, .line, .bubble") - .on("mousemove", function(d, i) { - const seriesIndex = determineSeriesIndexFromElement(this, isStacked); - const card = series[seriesIndex].card; - const isSingleSeriesBar = this.classList.contains("bar") && series.length === 1; - const isArea = this.classList.contains("area"); - - let data = []; - if (Array.isArray(d.key)) { // scatter - if (d.key._origin) { - data = d.key._origin.row.map((value, index) => { - const col = d.key._origin.cols[index]; - return { key: getFriendlyName(col), value: value, col }; - }); - } else { - data = d.key.map((value, index) => ( - { key: getFriendlyName(cols[index]), value: value, col: cols[index] } - )); + chart.selectAll("title").remove(); + + if (onHoverChange) { + chart.selectAll(".bar, .dot, .area, .line, .bubble") + .on("mousemove", function(d, i) { + const seriesIndex = determineSeriesIndexFromElement(this, isStacked); + const card = series[seriesIndex].card; + const isSingleSeriesBar = this.classList.contains("bar") && series.length === 1; + const isArea = this.classList.contains("area"); + + let data = []; + if (Array.isArray(d.key)) { // scatter + if (d.key._origin) { + data = d.key._origin.row.map((value, index) => { + const col = d.key._origin.cols[index]; + return { key: getFriendlyName(col), value: value, col }; + }); + } else { + data = d.key.map((value, index) => ( + { key: getFriendlyName(cols[index]), value: value, col: cols[index] } + )); + } + } else if (d.data) { // line, area, bar + if (!isSingleSeriesBar) { + cols = series[seriesIndex].data.cols; + } + data = [ + { key: getFriendlyName(cols[0]), value: d.data.key, col: cols[0] }, + { key: getFriendlyName(cols[1]), value: d.data.value, col: cols[1] } + ]; } - } else if (d.data) { // line, area, bar - if (!isSingleSeriesBar) { - cols = series[seriesIndex].data.cols; + + if (data && series.length > 1) { + if (card._breakoutColumn) { + data.unshift({ + key: getFriendlyName(card._breakoutColumn), + value: card._breakoutValue, + col: card._breakoutColumn + }); + } } - data = [ - { key: getFriendlyName(cols[0]), value: d.data.key, col: cols[0] }, - { key: getFriendlyName(cols[1]), value: d.data.value, col: cols[1] } - ]; - } - if (data && series.length > 1) { - if (card._breakoutColumn) { - data.unshift({ - key: getFriendlyName(card._breakoutColumn), + data = _.uniq(data, (d) => d.col); + + onHoverChange({ + // for single series bar charts, fade the series and highlght the hovered element with CSS + index: isSingleSeriesBar ? -1 : seriesIndex, + // for area charts, use the mouse location rather than the DOM element + element: isArea ? null : this, + event: isArea ? d3.event : null, + data: data.length > 0 ? data : null, + }); + }) + .on("mouseleave", function() { + if (!onHoverChange) { + return; + } + onHoverChange(null); + }) + } + + if (onVisualizationClick) { + chart.selectAll(".bar, .dot, .bubble") + .style({ "cursor": "pointer" }) + .on("mouseup", function(d) { + const seriesIndex = determineSeriesIndexFromElement(this, isStacked); + const card = series[seriesIndex].card; + const isSingleSeriesBar = this.classList.contains("bar") && series.length === 1; + + let clicked; + if (Array.isArray(d.key)) { // scatter + clicked = { + value: d.key[2], + column: cols[2], + dimensions: [ + { value: d.key[0], column: cols[0] }, + { value: d.key[1], column: cols[1] } + ], + origin: d.key._origin + } + } else if (d.data) { // line, area, bar + if (!isSingleSeriesBar) { + cols = series[seriesIndex].data.cols; + } + clicked = { + value: d.data.value, + column: cols[1], + dimensions: [ + { value: d.data.key, column: cols[0] } + ] + } + } + + if (clicked && series.length > 1 && card._breakoutColumn) { + clicked.dimensions.push({ value: card._breakoutValue, - col: card._breakoutColumn + column: card._breakoutColumn }); } - } - - data = _.uniq(data, (d) => d.col); - onHoverChange && onHoverChange({ - // for single series bar charts, fade the series and highlght the hovered element with CSS - index: isSingleSeriesBar ? -1 : seriesIndex, - // for area charts, use the mouse location rather than the DOM element - element: isArea ? null : this, - event: isArea ? d3.event : null, - data: data.length > 0 ? data : null, + if (clicked) { + const isLine = this.classList.contains("dot"); + onVisualizationClick({ + ...clicked, + element: isLine ? this : null, + event: isLine ? null : d3.event, + }); + } }); - }) - .on("mouseleave", function() { - onHoverChange && onHoverChange(null); - }); - - chart.selectAll("title").remove(); + } }); } @@ -487,6 +542,10 @@ function lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis, isStacked dispatchUIEvent(e, "mouseleave"); d3.select(e).classed("hover", false); }) + .on("mouseup", ({ point }) => { + let e = point[2]; + dispatchUIEvent(e, "mouseup"); + }) .order(); function dispatchUIEvent(element, eventName) { @@ -746,7 +805,7 @@ function forceSortedGroupsOfGroups(groupsOfGroups: CrossfilterGroup[][], indexMa } -export default function lineAreaBar(element, { series, onHoverChange, onRender, chartType, isScalarSeries, settings, maxSeries }) { +export default function lineAreaBar(element, { series, onHoverChange, onVisualizationClick, onRender, chartType, isScalarSeries, settings, maxSeries }) { const colors = settings["graph.colors"]; const isTimeseries = settings["graph.x_axis.scale"] === "timeseries"; @@ -1073,7 +1132,7 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, } onHoverChange(hovered); } - }); + }, onVisualizationClick); // render parent.render(); @@ -1101,10 +1160,19 @@ export const scatterRenderer = (element, props) => lineAreaBar(element, { ...pro export function rowRenderer( element, - { settings, series, onHoverChange, height } + { settings, series, onHoverChange, onVisualizationClick, height } ) { + const { cols } = series[0].data; + + if (series.length > 1) { + throw new Error("Row chart does not support multiple series"); + } + const chart = dc.rowChart(element); + // disable clicks + chart.onClick = () => {}; + const colors = settings["graph.colors"]; const dataset = crossfilter(series[0].data.rows); @@ -1118,20 +1186,35 @@ export function rowRenderer( initChart(chart, element); chart.on("renderlet.tooltips", chart => { - chart.selectAll(".row rect").on("mousemove", (d, i) => { - const { cols } = series[0].data; - onHoverChange && onHoverChange({ - // for single series bar charts, fade the series and highlght the hovered element with CSS - index: -1, - event: d3.event, - data: [ - { key: getFriendlyName(cols[0]), value: d.key, col: cols[0] }, - { key: getFriendlyName(cols[1]), value: d.value, col: cols[1] } - ] - }); - }).on("mouseleave", () => { - onHoverChange && onHoverChange(null); - }); + if (onHoverChange) { + chart.selectAll(".row rect").on("mousemove", (d, i) => { + onHoverChange && onHoverChange({ + // for single series bar charts, fade the series and highlght the hovered element with CSS + index: -1, + event: d3.event, + data: [ + { key: getFriendlyName(cols[0]), value: d.key, col: cols[0] }, + { key: getFriendlyName(cols[1]), value: d.value, col: cols[1] } + ] + }); + }).on("mouseleave", () => { + onHoverChange && onHoverChange(null); + }); + } + + if (onVisualizationClick) { + chart.selectAll(".row rect").on("mouseup", function(d) { + onVisualizationClick({ + value: d.value, + column: cols[1], + dimensions: [{ + value: d.key, + column: cols[0] + }], + element: this + }) + }); + } }); chart diff --git a/frontend/src/metabase/visualizations/visualizations/Funnel.jsx b/frontend/src/metabase/visualizations/visualizations/Funnel.jsx index 8ed850eb82a488cb8c0362944fb6ea037f8b63a8..4fd4fb8ef028bf4a1527415d56701387613a9fb5 100644 --- a/frontend/src/metabase/visualizations/visualizations/Funnel.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Funnel.jsx @@ -15,7 +15,7 @@ import LegendHeader from "../components/LegendHeader"; import _ from "underscore"; import cx from "classnames"; -import type { VisualizationProps } from ".."; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; export default class Funnel extends Component<*, VisualizationProps, *> { static uiName = "Funnel"; @@ -33,7 +33,12 @@ export default class Funnel extends Component<*, VisualizationProps, *> { return cols.length === 2; } - static checkRenderable([{ data: { cols, rows} }], settings) { + static checkRenderable(series, settings) { + const [{ data: { rows} }] = series; + if (series.length > 1) { + return; + } + if (rows.length < 1) { throw new MinRowsError(1, rows.length); } diff --git a/frontend/src/metabase/visualizations/visualizations/Map.jsx b/frontend/src/metabase/visualizations/visualizations/Map.jsx index a1a32dee10936b6354b7c1d2ee908f4a2d143210..d552cc87a7f73032b44ff851b3bf9b78b6ae5d9e 100644 --- a/frontend/src/metabase/visualizations/visualizations/Map.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Map.jsx @@ -10,7 +10,7 @@ import { isNumeric, isLatitude, isLongitude, hasLatitudeAndLongitudeColumns } fr import { metricSetting, dimensionSetting, fieldSetting } from "metabase/visualizations/lib/settings"; import MetabaseSettings from "metabase/lib/settings"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; import _ from "underscore"; diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx index 0efb2e80edf190ac1513a3a5e70d0b567aff3141..b922e95cb76714c4bd966056a2e65ff4b5922392 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx @@ -29,7 +29,7 @@ const OTHER_SLICE_MIN_PERCENTAGE = 0.003; const PERCENT_REGEX = /percent/i; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; type Props = VisualizationProps; @@ -90,7 +90,7 @@ export default class PieChart extends Component<*, Props, *> { } render() { - const { series, hovered, onHoverChange, className, gridSize, settings } = this.props; + const { series, hovered, onHoverChange, onVisualizationClick, className, gridSize, settings } = this.props; const [{ data: { cols, rows }}] = series; const dimensionIndex = _.findIndex(cols, (col) => col.name === settings["pie.dimension"]); @@ -154,7 +154,7 @@ export default class PieChart extends Component<*, Props, *> { .outerRadius(OUTER_RADIUS) .innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO); - let hoverForIndex = (index, event) => ({ + const hoverForIndex = (index, event) => ({ index, event: event && event.nativeEvent, data: slices[index] === otherSlice ? @@ -168,6 +168,20 @@ export default class PieChart extends Component<*, Props, *> { ].concat(showPercentInTooltip ? [{ key: "Percentage", value: formatPercent(slices[index].percentage) }] : []) }); + const onClickSlice = ({ index, event }) => { + if (onVisualizationClick && slices[index] !== otherSlice) { + onVisualizationClick({ + value: slices[index].value, + column: cols[metricIndex], + dimensions: [{ + value: slices[index].key, + column: cols[dimensionIndex], + }], + event: event + }) + } + } + let value, title; if (hovered && hovered.index != null && slices[hovered.index] !== otherSlice) { title = slices[hovered.index].key; @@ -201,6 +215,10 @@ export default class PieChart extends Component<*, Props, *> { opacity={(hovered && hovered.index != null && hovered.index !== index) ? 0.3 : 1} onMouseMove={(e) => onHoverChange && onHoverChange(hoverForIndex(index, e))} onMouseLeave={() => onHoverChange && onHoverChange(null)} + onClick={(e) => onClickSlice({ + index: index, + event: e.nativeEvent + })} /> )} </g> diff --git a/frontend/src/metabase/visualizations/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/visualizations/Progress.jsx index 15ae1c1c09029503b613ec9ebec85fa16c3930fc..1189f5d1afcf797b275fcd31bcb3d34e8f33c9cf 100644 --- a/frontend/src/metabase/visualizations/visualizations/Progress.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Progress.jsx @@ -15,7 +15,7 @@ import cx from "classnames"; const BORDER_RADIUS = 5; const MAX_BAR_HEIGHT = 65; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; export default class Progress extends Component<*, VisualizationProps, *> { static uiName = "Progress"; @@ -102,7 +102,7 @@ export default class Progress extends Component<*, VisualizationProps, *> { } render() { - const { series: [{ data: { rows } }], settings } = this.props; + const { series: [{ data: { rows, cols } }], settings, onVisualizationClick, visualizationIsClickable } = this.props; const value: number = rows[0][0]; const goal = settings["progress.goal"] || 0; @@ -124,6 +124,12 @@ export default class Progress extends Component<*, VisualizationProps, *> { barMessage = "Goal exceeded"; } + const clicked = { + value: value, + column: cols[0] + }; + const isClickable = visualizationIsClickable(clicked); + return ( <div className={cx(this.props.className, "flex layout-centered")}> <div className="flex-full full-height flex flex-column justify-center" style={{ padding: 10, paddingTop: 0 }}> @@ -154,11 +160,16 @@ export default class Progress extends Component<*, VisualizationProps, *> { }} /> </div> - <div ref="bar" className="relative" style={{ - backgroundColor: restColor, - borderRadius: BORDER_RADIUS, - overflow: "hidden" - }}> + <div + ref="bar" + className={cx("relative", { "cursor-pointer": isClickable })} + style={{ + backgroundColor: restColor, + borderRadius: BORDER_RADIUS, + overflow: "hidden" + }} + onClick={isClickable && ((e) => onVisualizationClick({ ...clicked, event: e.nativeEvent }))} + > <div style={{ backgroundColor: progressColor, width: (barPercent * 100) + "%", diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.css b/frontend/src/metabase/visualizations/visualizations/Scalar.css index b4f60531862f850b77f7f33390aac62da629789d..0a372b305caf7145fbcf067662a3931b255e36f0 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.css +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.css @@ -6,7 +6,6 @@ color: #525658; } :local .Scalar .Value { - color: rgb(31,31,31); font-weight: bold; } :local .Scalar .Title { diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 4fb0a1a5b7e2929b74dbf2903a6c4a30870ec503..0109394d22e216b61bba2ae5395b0ce4892f1645 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -14,10 +14,9 @@ import { TYPE } from "metabase/lib/types"; import { isNumber } from "metabase/lib/schema_metadata"; import cx from "classnames"; -import { getIn } from "icepick"; import d3 from "d3"; -import type { VisualizationProps } from "metabase/visualizations"; +import type { VisualizationProps } from "metabase/meta/types/Visualization"; export default class Scalar extends Component<*, VisualizationProps, *> { static uiName = "Number"; @@ -29,6 +28,8 @@ export default class Scalar extends Component<*, VisualizationProps, *> { static minSize = { width: 3, height: 3 }; + _scalar: ?HTMLElement; + static isSensible(cols, rows) { return rows.length === 1 && cols.length === 1; } @@ -102,13 +103,13 @@ export default class Scalar extends Component<*, VisualizationProps, *> { }; render() { - let { card, data, className, actionButtons, gridSize, settings, linkToCard } = this.props; + let { series: [{ card, data: { cols, rows }}], className, actionButtons, gridSize, settings, linkToCard, visualizationIsClickable, onVisualizationClick } = this.props; let description = settings["card.description"]; let isSmall = gridSize && gridSize.width < 4; - const column = getIn(data, ["cols", 0]); + const column = cols[0]; - let scalarValue = getIn(data, ["rows", 0, 0]); + let scalarValue = rows[0] && rows[0][0]; if (scalarValue == null) { scalarValue = ""; } @@ -167,16 +168,29 @@ export default class Scalar extends Component<*, VisualizationProps, *> { fullScalarValue = fullScalarValue + settings["scalar.suffix"]; } + const clicked = { + value: rows[0] && rows[0][0], + column: cols[0] + }; + const isClickable = visualizationIsClickable(clicked); + return ( <div className={cx(className, styles.Scalar, styles[isSmall ? "small" : "large"])}> <div className="Card-title absolute top right p1 px2">{actionButtons}</div> <Ellipsified - className={cx(styles.Value, 'ScalarValue', 'fullscreen-normal-text', 'fullscreen-night-text')} + className={cx(styles.Value, 'ScalarValue text-dark fullscreen-normal-text fullscreen-night-text', { + "text-brand-hover cursor-pointer": isClickable + })} tooltip={fullScalarValue} alwaysShowTooltip={fullScalarValue !== compactScalarValue} style={{maxWidth: '100%'}} > - {compactScalarValue} + <span + onClick={isClickable && (() => this._scalar && onVisualizationClick({ ...clicked, element: this._scalar }))} + ref={scalar => this._scalar = scalar} + > + {compactScalarValue} + </span> </Ellipsified> <div className={styles.Title + " flex align-center"}> <Ellipsified tooltip={card.name}> diff --git a/frontend/src/metabase/visualizations/visualizations/Table.jsx b/frontend/src/metabase/visualizations/visualizations/Table.jsx index 1b186d023a2bfeffece08921a98e00e94834f31a..bd9477488b93f34145278e3f5ddb0e23f4e34bfc 100644 --- a/frontend/src/metabase/visualizations/visualizations/Table.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Table.jsx @@ -24,13 +24,9 @@ type Props = { data: DatasetData, settings: VisualizationSettings, isDashboard: boolean, - cellClickedFn: (number, number) => void, - cellIsClickableFn: (number, number) => boolean, - setSortFn: (/* TODO */) => void, } type State = { - data: ?DatasetData, - columnIndexes: number[] + data: ?DatasetData } export default class Table extends Component<*, Props, State> { @@ -87,8 +83,7 @@ export default class Table extends Component<*, Props, State> { super(props); this.state = { - data: null, - columnIndexes: [] + data: null }; } @@ -103,14 +98,6 @@ export default class Table extends Component<*, Props, State> { } } - cellClicked = (rowIndex: number, columnIndex: number, ...args: any[]) => { - this.props.cellClickedFn(rowIndex, this.state.columnIndexes[columnIndex], ...args); - } - - cellIsClickable = (rowIndex: number, columnIndex: number, ...args: any[]) => { - return this.props.cellIsClickableFn(rowIndex, this.state.columnIndexes[columnIndex], ...args); - } - _updateData({ data, settings }: { data: DatasetData, settings: VisualizationSettings }) { if (settings["table.pivot"]) { this.setState({ @@ -128,14 +115,13 @@ export default class Table extends Component<*, Props, State> { cols: columnIndexes.map(i => cols[i]), columns: columnIndexes.map(i => columns[i]), rows: rows.map(row => columnIndexes.map(i => row[i])) - }, - columnIndexes + } }); } } render() { - const { card, cellClickedFn, cellIsClickableFn, setSortFn, isDashboard, settings } = this.props; + const { card, isDashboard, settings } = this.props; const { data } = this.state; const sort = getIn(card, ["dataset_query", "query", "order_by"]) || null; const isPivoted = settings["table.pivot"]; @@ -146,9 +132,6 @@ export default class Table extends Component<*, Props, State> { data={data} isPivoted={isPivoted} sort={sort} - setSortFn={isPivoted ? undefined : setSortFn} - cellClickedFn={(!cellClickedFn || isPivoted) ? undefined : this.cellClicked} - cellIsClickableFn={(!cellIsClickableFn || isPivoted) ? undefined : this.cellIsClickable} /> ); } diff --git a/frontend/test/e2e/admin/datamodel.spec.js b/frontend/test/e2e/admin/datamodel.spec.js index fc129af68e27decc42573b81b1ea995b13340628..10e9a67e6d43e50bfaaa5283fe42141da0899eb1 100644 --- a/frontend/test/e2e/admin/datamodel.spec.js +++ b/frontend/test/e2e/admin/datamodel.spec.js @@ -55,7 +55,7 @@ describeE2E("admin/datamodel", () => { await waitForElementAndClick(driver, "#FilterPopover .List-item:nth-child(4)>a"); const addFilterButton = findElement(driver, "#FilterPopover .Button.disabled"); await waitForElementAndClick(driver, "#OperatorSelector .Button.Button-normal.Button--medium:nth-child(2)"); - await waitForElementAndSendKeys(driver, "#FilterPopover input.border-purple", 'gmail'); + await waitForElementAndSendKeys(driver, "#FilterPopover textarea.border-purple", 'gmail'); expect(await addFilterButton.isEnabled()).toBe(true); await addFilterButton.click(); diff --git a/frontend/test/unit/lib/data_grid.spec.js b/frontend/test/unit/lib/data_grid.spec.js index 0ad32a2f31dc007c9a42bff4f61553939db19abb..fd968a0cbd64e53a57ea0d65643ae990cb3adb52 100644 --- a/frontend/test/unit/lib/data_grid.spec.js +++ b/frontend/test/unit/lib/data_grid.spec.js @@ -27,7 +27,7 @@ describe("data_grid", () => { ]) let pivotedData = pivot(data); expect(pivotedData.cols.length).toEqual(3); - expect(pivotedData.rows).toEqual([ + expect(pivotedData.rows.map(row => [...row])).toEqual([ ["x", 1, 4], ["y", 2, 5], ["z", 3, 6] diff --git a/package.json b/package.json index ace8b699ed34df85e90cb1be03d9f6741621b91b..8cebcc3d1696c7a4c1b6cf3a107c98c47033f1d4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "classnames": "^2.1.3", "color": "^1.0.3", "crossfilter": "^1.3.12", + "cxs": "^3.0.4", "d3": "^3.5.17", "dc": "^2.0.0", "diff": "^3.2.0", @@ -102,6 +103,7 @@ "fs-promise": "^1.0.0", "glob": "^7.1.1", "html-webpack-plugin": "^2.14.0", + "husky": "^0.13.2", "image-diff": "^1.6.3", "imports-loader": "^0.7.0", "jasmine": "^2.4.1", @@ -118,11 +120,13 @@ "karma-junit-reporter": "^1.1.0", "karma-nyan-reporter": "^0.2.2", "karma-webpack": "^1.7.0", + "lint-staged": "^3.3.1", "loader-utils": "^0.2.12", "postcss-cssnext": "^2.4.0", "postcss-import": "^9.0.0", "postcss-loader": "^1.2.1", "postcss-url": "^5.1.1", + "prettier": "^0.21.0", "promise-loader": "^1.0.0", "react-addons-test-utils": "^15.4.2", "react-hot-loader": "^1.3.0", @@ -139,7 +143,9 @@ }, "scripts": { "dev": "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'", - "lint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test", + "lint": "yarn run lint-eslint && yarn run lint-prettier", + "lint-eslint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test", + "lint-prettier": "prettier --tab-width 4 -l 'frontend/src/metabase/qb/**/*.js*' 'frontend/src/metabase/new_question/**/*.js*' || (echo '\nThese files are not formatted correctly. Did you forget to run \"yarn run prettier\"?' && false)", "flow": "flow check", "test": "karma start frontend/test/karma.conf.js --single-run", "test-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", @@ -150,9 +156,21 @@ "build-hot": "NODE_ENV=hot webpack --bail && NODE_ENV=hot webpack-dev-server --progress", "start": "yarn run build && lein ring server", "storybook": "start-storybook -p 9001", + "precommit": "lint-staged", "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'", + "prettier": "prettier --tab-width 4 --write 'frontend/src/metabase/qb/**/*.js*' 'frontend/src/metabase/new_question/**/*.js*'", "test-jest": "jest" }, + "lint-staged": { + "frontend/src/metabase/qb/**/*.js*": [ + "prettier --tab-width 4 --write", + "git add" + ], + "frontend/src/metabase/new_question/**/*.js*": [ + "prettier --tab-width 4 --write", + "git add" + ] + }, "jest": { "testPathIgnorePatterns": [ "<rootDir>/frontend/test/" diff --git a/project.clj b/project.clj index bb19cac54f330a2fa6f67e63e5e1ec296908114f..9177e9125445f387d2b1e67943478345bd0c1026 100644 --- a/project.clj +++ b/project.clj @@ -27,7 +27,7 @@ [amalloy/ring-gzip-middleware "0.1.3"] ; Ring middleware to GZIP responses if client can handle it [aleph "0.4.1"] ; Async HTTP library; WebSockets [buddy/buddy-core "1.2.0"] ; various cryptograhpic functions - [buddy/buddy-sign "1.1.0"] ; JSON Web Tokens; High-Level message signing library + [buddy/buddy-sign "1.5.0"] ; JSON Web Tokens; High-Level message signing library [cheshire "5.7.0"] ; fast JSON encoding (used by Ring JSON middleware) [clj-http "3.4.1" ; HTTP client :exclusions [commons-codec @@ -71,7 +71,7 @@ [org.yaml/snakeyaml "1.17"] ; YAML parser (required by liquibase) [org.xerial/sqlite-jdbc "3.8.11.2"] ; SQLite driver !!! DO NOT UPGRADE THIS UNTIL UPSTREAM BUG IS FIXED -- SEE https://github.com/metabase/metabase/issues/3753 !!! [postgresql "9.3-1102.jdbc41"] ; Postgres driver - [io.crate/crate-jdbc "2.1.5"] ; Crate JDBC driver + [io.crate/crate-jdbc "2.1.6"] ; Crate JDBC driver [prismatic/schema "1.1.3"] ; Data schema declaration and validation library [ring/ring-jetty-adapter "1.5.1"] ; Ring adapter using Jetty webserver (used to run a Ring server for unit tests) [ring/ring-json "0.4.0"] ; Ring middleware for reading/writing JSON automatically diff --git a/resources/frontend_client/index_template.html b/resources/frontend_client/index_template.html index 029a063732ed56f1032b024e1258f30871a12b25..5086fc555cda70e69ef449c369df68184726e109 100644 --- a/resources/frontend_client/index_template.html +++ b/resources/frontend_client/index_template.html @@ -33,7 +33,7 @@ document.body.appendChild(script); } loadScript('https://ajax.googleapis.com/ajax/libs/webfont/1.4.7/webfont.js', function () { - WebFont.load({ google: { families: ["Lato:n3,n4,n7"] } }); + WebFont.load({ google: { families: ["Lato:n3,n4,n9"] } }); }); var googleAuthClientID = window.MetabaseBootstrap.google_auth_client_id; diff --git a/resources/migrations/054_add_pulse_skip_if_empty.yaml b/resources/migrations/054_add_pulse_skip_if_empty.yaml new file mode 100644 index 0000000000000000000000000000000000000000..054cf11db5e464d73a2e5e815b27debf943896dc --- /dev/null +++ b/resources/migrations/054_add_pulse_skip_if_empty.yaml @@ -0,0 +1,15 @@ +databaseChangeLog: + - changeSet: + id: 54 + author: tlrobinson + changes: + - addColumn: + tableName: pulse + remarks: 'Skip a scheduled Pulse if none of its questions have any results' + columns: + - column: + name: skip_if_empty + type: boolean + defaultValueBoolean: false + constraints: + nullable: false diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 975dbd0f82bdf08765592bf1f7f2e2034859fe1f..c83433a7dfe6f79ab00de214f528e7c6c9fba0d1 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -143,14 +143,16 @@ "Get a list of all `Fields` in `Database`." [id] (read-check Database id) - (for [{:keys [id display_name table]} (filter mi/can-read? (-> (db/select [Field :id :display_name :table_id] - :table_id [:in (db/select-field :id Table, :db_id id)] - :visibility_type [:not-in ["sensitive" "retired"]]) - (hydrate :table)))] - {:id id - :name display_name - :table_name (:display_name table) - :schema (:schema table)})) + (for [{:keys [id display_name table base_type special_type]} (filter mi/can-read? (-> (db/select [Field :id :display_name :table_id :base_type :special_type] + :table_id [:in (db/select-field :id Table, :db_id id)] + :visibility_type [:not-in ["sensitive" "retired"]]) + (hydrate :table)))] + {:id id + :name display_name + :base_type base_type + :special_type special_type + :table_name (:display_name table) + :schema (:schema table)})) ;;; ------------------------------------------------------------ GET /api/database/:id/idfields ------------------------------------------------------------ diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index a70a9eb53a0afd3f54aca3d464ce2fc1245d0219..5f713e6212dad3fbfb7260e96c61e5bb5276cfcb 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -125,12 +125,14 @@ "Transforms native query's `template_tags` into `parameters`." [card] ;; NOTE: this should mirror `getTemplateTagParameters` in frontend/src/metabase/meta/Parameter.js - (for [[_ {tag-type :type, :as tag}] (get-in card [:dataset_query :native :template_tags]) + (for [[_ {tag-type :type, widget-type :widget_type, :as tag}] (get-in card [:dataset_query :native :template_tags]) :when (and tag-type - (not= tag-type "dimension"))] + (or widget-type (not= tag-type "dimension")))] {:id (:id tag) - :type (if (= tag-type "date") "date/single" "category") - :target ["variable" ["template-tag" (:name tag)]] + :type (or widget-type (if (= tag-type "date") "date/single" "category")) + :target (if (= tag-type "dimension") + ["dimension" ["template-tag" (:name tag)]] + ["variable" ["template-tag" (:name tag)]]) :name (:display_name tag) :slug (:name tag) :default (:default tag)})) diff --git a/src/metabase/api/field.clj b/src/metabase/api/field.clj index ecb202d74a8a988ba26610221ae2a53ff25b8e4b..4729dd7fb1d2e4ad1d065847eabd60308c45ee56 100644 --- a/src/metabase/api/field.clj +++ b/src/metabase/api/field.clj @@ -78,6 +78,7 @@ (create-field-values-if-needed! field)))) +;; TODO - not sure this is used anymore (defendpoint POST "/:id/value_map_update" "Update the human-readable values for a `Field` whose special type is `category`/`city`/`state`/`country` or whose base type is `type/Boolean`." diff --git a/src/metabase/api/metric.clj b/src/metabase/api/metric.clj index cc52980b363a4afedfdc4fe818fbc57db592bac2..fcee3655552f5755b072f3df84fd00512e611d4c 100644 --- a/src/metabase/api/metric.clj +++ b/src/metabase/api/metric.clj @@ -31,12 +31,20 @@ (check-superuser) (read-check (metric/retrieve-metric id))) +(defn- add-db-ids + "Add `:database_id` fields to METRICS by looking them up from their `:table_id`." + [metrics] + (when (seq metrics) + (let [table-id->db-id (db/select-id->field :db_id Table, :id [:in (set (map :table_id metrics))])] + (for [metric metrics] + (assoc metric :database_id (table-id->db-id (:table_id metric))))))) (defendpoint GET "/" "Fetch *all* `Metrics`." [id] (filter mi/can-read? (-> (db/select Metric, :is_active true, {:order-by [:%lower.name]}) - (hydrate :creator)))) + (hydrate :creator) + add-db-ids))) (defendpoint PUT "/:id" diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index 14434e35ed49f2d4ba9db1b805d59c6d3d359a65..df9bf545f23dcfb09ec7a0c5e928170e42ed9e81 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -2,6 +2,7 @@ "/api/pulse endpoints." (:require [compojure.core :refer [defroutes GET PUT POST DELETE]] [hiccup.core :refer [html]] + [schema.core :as s] [metabase.api.common :refer :all] [toucan.db :as db] [metabase.email :as email] @@ -38,12 +39,13 @@ (defendpoint POST "/" "Create a new `Pulse`." - [:as {{:keys [name cards channels]} :body}] - {name su/NonBlankString - cards (su/non-empty [su/Map]) - channels (su/non-empty [su/Map])} + [:as {{:keys [name cards channels skip_if_empty]} :body}] + {name su/NonBlankString + cards (su/non-empty [su/Map]) + channels (su/non-empty [su/Map]) + skip_if_empty s/Bool} (check-card-read-permissions cards) - (check-500 (pulse/create-pulse! name *current-user-id* (map u/get-id cards) channels))) + (check-500 (pulse/create-pulse! name *current-user-id* (map u/get-id cards) channels skip_if_empty))) (defendpoint GET "/:id" @@ -55,16 +57,18 @@ (defendpoint PUT "/:id" "Update a `Pulse` with ID." - [id :as {{:keys [name cards channels]} :body}] - {name su/NonBlankString - cards (su/non-empty [su/Map]) - channels (su/non-empty [su/Map])} + [id :as {{:keys [name cards channels skip_if_empty]} :body}] + {name su/NonBlankString + cards (su/non-empty [su/Map]) + channels (su/non-empty [su/Map]) + skip_if_empty s/Bool} (write-check Pulse id) (check-card-read-permissions cards) - (pulse/update-pulse! {:id id - :name name - :cards (map u/get-id cards) - :channels channels}) + (pulse/update-pulse! {:id id + :name name + :cards (map u/get-id cards) + :channels channels + :skip-if-empty? skip_if_empty}) (pulse/retrieve-pulse id)) @@ -130,10 +134,11 @@ (defendpoint POST "/test" "Test send an unsaved pulse." - [:as {{:keys [name cards channels] :as body} :body}] - {name su/NonBlankString - cards (su/non-empty [su/Map]) - channels (su/non-empty [su/Map])} + [:as {{:keys [name cards channels skip_if_empty] :as body} :body}] + {name su/NonBlankString + cards (su/non-empty [su/Map]) + channels (su/non-empty [su/Map]) + skip_if_empty s/Bool} (check-card-read-permissions cards) (p/send-pulse! body) {:ok true}) diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index 0d2da6f8c2c675a2e1b97a138cbbe942de133a88..20ed2d16ff23ed87ca3a8d8bc7fae9f68015b08f 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -60,9 +60,6 @@ (context "/field" [] (+auth field/routes)) (context "/getting_started" [] (+auth getting-started/routes)) (context "/geojson" [] (+auth geojson/routes)) - (GET "/health" [] (if ((resolve 'metabase.core/initialized?)) - {:status 200, :body {:status "ok"}} - {:status 503, :body {:status "initializing", :progress ((resolve 'metabase.core/initialization-progress))}})) (context "/label" [] (+auth label/routes)) (context "/metric" [] (+auth metric/routes)) (context "/notify" [] (+apikey notify/routes)) diff --git a/src/metabase/core.clj b/src/metabase/core.clj index 2b8a82c194b8bba0bb7c4735b10aa9851b1b67cb..571ae867587aa8bcbe03f53e6a779b01e91091f1 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -1,7 +1,8 @@ ;; -*- comment-column: 35; -*- (ns metabase.core (:gen-class) - (:require [clojure.string :as s] + (:require (clojure [pprint :as pprint] + [string :as s]) [clojure.tools.logging :as log] environ.core [ring.adapter.jetty :as ring-jetty] @@ -14,8 +15,9 @@ [session :refer [wrap-session]]) [medley.core :as m] [toucan.db :as db] - (metabase [config :as config] - [db :as mdb] + [metabase.config :as config] + [metabase.core.initialization-status :as init-status] + (metabase [db :as mdb] [driver :as driver] [events :as events] [logger :as logger] @@ -54,23 +56,7 @@ ;;; ## ---------------------------------------- LIFECYCLE ---------------------------------------- -(defonce ^:private metabase-initialization-progress - (atom 0)) -(defn initialized? - "Is Metabase initialized and ready to be served?" - [] - (= @metabase-initialization-progress 1.0)) - -(defn initialization-progress - "Get the current progress of Metabase initialization." - [] - @metabase-initialization-progress) - -(defn initialization-complete! - "Complete the Metabase initialization by setting its progress to 100%." - [] - (reset! metabase-initialization-progress 1.0)) (defn- -init-create-setup-token "Create and set a new setup token and log it." @@ -97,46 +83,47 @@ [] (log/info (format "Starting Metabase version %s ..." config/mb-version-string)) (log/info (format "System timezone is '%s' ..." (System/getProperty "user.timezone"))) - (reset! metabase-initialization-progress 0.1) + (init-status/set-progress! 0.1) ;; First of all, lets register a shutdown hook that will tidy things up for us on app exit (.addShutdownHook (Runtime/getRuntime) (Thread. ^Runnable destroy!)) - (reset! metabase-initialization-progress 0.2) + (init-status/set-progress! 0.2) ;; load any plugins as needed (plugins/load-plugins!) - (reset! metabase-initialization-progress 0.3) + (init-status/set-progress! 0.3) ;; Load up all of our Database drivers, which are used for app db work (driver/find-and-load-drivers!) - (reset! metabase-initialization-progress 0.4) + (init-status/set-progress! 0.4) ;; startup database. validates connection & runs any necessary migrations + (log/info "Setting up and migrating Metabase DB. Please sit tight, this may take a minute...") (mdb/setup-db! :auto-migrate (config/config-bool :mb-db-automigrate)) - (reset! metabase-initialization-progress 0.5) + (init-status/set-progress! 0.5) ;; run a very quick check to see if we are doing a first time installation ;; the test we are using is if there is at least 1 User in the database - (let [new-install (not (db/exists? User))] + (let [new-install? (not (db/exists? User))] ;; Bootstrap the event system (events/initialize-events!) - (reset! metabase-initialization-progress 0.7) + (init-status/set-progress! 0.7) ;; Now start the task runner (task/start-scheduler!) - (reset! metabase-initialization-progress 0.8) + (init-status/set-progress! 0.8) - (when new-install + (when new-install? (log/info "Looks like this is a new installation ... preparing setup wizard") ;; create setup token (-init-create-setup-token) ;; publish install event (events/publish-event! :install {})) - (reset! metabase-initialization-progress 0.9) + (init-status/set-progress! 0.9) ;; deal with our sample dataset as needed - (if new-install + (if new-install? ;; add the sample dataset DB for fresh installs (sample-data/add-sample-dataset!) ;; otherwise update if appropriate @@ -145,7 +132,7 @@ ;; start the metabot thread (metabot/start-metabot!)) - (initialization-complete!) + (init-status/set-complete!) (log/info "Metabase Initialization COMPLETE")) @@ -173,7 +160,8 @@ (config/config-str :mb-jetty-daemon) (assoc :daemon? (config/config-bool :mb-jetty-daemon)) (config/config-str :mb-jetty-ssl) (-> (assoc :ssl? true) (merge jetty-ssl-config)))] - (log/info "Launching Embedded Jetty Webserver with config:\n" (with-out-str (clojure.pprint/pprint (m/filter-keys (fn [k] (not (re-matches #".*password.*" (str k)))) jetty-config)))) + (log/info "Launching Embedded Jetty Webserver with config:\n" (with-out-str (pprint/pprint (m/filter-keys #(not (re-matches #".*password.*" (str %))) + jetty-config)))) ;; NOTE: we always start jetty w/ join=false so we can start the server first then do init in the background (->> (ring-jetty/run-jetty app (assoc jetty-config :join? false)) (reset! jetty-instance))))) diff --git a/src/metabase/core/initialization_status.clj b/src/metabase/core/initialization_status.clj new file mode 100644 index 0000000000000000000000000000000000000000..953d6040f0b9d6f8cf1d0809931bedf1ac268ebe --- /dev/null +++ b/src/metabase/core/initialization_status.clj @@ -0,0 +1,28 @@ +(ns metabase.core.initialization-status + "Code related to tracking the progress of metabase initialization. + This is kept in a separate, tiny namespace so it can be loaded right away when the application launches + (and so we don't need to wait for `metabase.core` to load to check the status).") + +(defonce ^:private progress-atom + (atom 0)) + +(defn complete? + "Is Metabase initialized and ready to be served?" + [] + (= @progress-atom 1.0)) + +(defn progress + "Get the current progress of Metabase initialization." + [] + @progress-atom) + +(defn set-progress! + "Update the Metabase initialization progress to a new value, a floating-point value between `0` and `1`." + [^Float new-progress] + {:pre [(float? new-progress) (<= 0.0 new-progress 1.0)]} + (reset! progress-atom new-progress)) + +(defn set-complete! + "Complete the Metabase initialization by setting its progress to 100%." + [] + (set-progress! 1.0)) diff --git a/src/metabase/db.clj b/src/metabase/db.clj index 044bb3f922d2009129bae9deb4a715fac71dee21..75fcf7b19b7ff3c9325796c3ab3867bdff9c81a3 100644 --- a/src/metabase/db.clj +++ b/src/metabase/db.clj @@ -19,6 +19,7 @@ metabase.util.honeysql-extensions) ; this needs to be loaded so the `:h2` quoting style gets added (:import java.io.StringWriter java.sql.Connection + java.util.Properties com.mchange.v2.c3p0.ComboPooledDataSource liquibase.Liquibase (liquibase.database DatabaseFactory Database) @@ -268,7 +269,7 @@ (.setTestConnectionOnCheckin false) (.setTestConnectionOnCheckout false) (.setPreferredTestQuery nil) - (.setProperties (u/prog1 (java.util.Properties.) + (.setProperties (u/prog1 (Properties.) (doseq [[k v] (dissoc spec :classname :subprotocol :subname :naming :delimiters :alias-delimiter :excess-timeout :minimum-pool-size :idle-connection-test-period)] (.setProperty <> (name k) (str v))))))}) @@ -289,6 +290,11 @@ (def ^:private setup-db-has-been-called? (atom false)) +(defn db-is-setup? + "True if the Metabase DB is setup and ready." + ^Boolean [] + @setup-db-has-been-called?) + (def ^:dynamic *allow-potentailly-unsafe-connections* "We want to make *every* database connection made by the drivers safe -- read-only, only connect if DB file exists, etc. At the same time, we'd like to be able to use driver functionality like `can-connect-with-details?` to check whether we can @@ -360,11 +366,11 @@ [& {:keys [db-details auto-migrate] :or {db-details @db-connection-details auto-migrate true}}] - (reset! setup-db-has-been-called? true) (verify-db-connection db-details) (run-schema-migrations! auto-migrate db-details) (create-connection-pool! (jdbc-details db-details)) - (run-data-migrations!)) + (run-data-migrations!) + (reset! setup-db-has-been-called? true)) (defn setup-db-if-needed! "Call `setup-db!` if DB is not already setup; otherwise this does nothing." diff --git a/src/metabase/db/spec.clj b/src/metabase/db/spec.clj index 969dfbd97f9f49dc1311f292d17c00bc94bca115..3b7454c891e447494a631bfe51742e9f8cb3720c 100644 --- a/src/metabase/db/spec.clj +++ b/src/metabase/db/spec.clj @@ -1,5 +1,7 @@ (ns metabase.db.spec - "Functions for creating JDBC DB specs for a given engine.") + "Functions for creating JDBC DB specs for a given engine. + Only databases that are supported as application DBs should have functions in this namespace; + otherwise, similar functions are only needed by drivers, and belong in those namespaces.") (defn h2 "Create a database specification for a h2 database. Opts should include a key @@ -36,38 +38,3 @@ :subname (str "//" host ":" port "/" db) :delimiters "`"} (dissoc opts :host :port :db))) - - -;; TODO - These other ones can acutally be moved directly into their respective drivers themselves since they're not supported as backing DBs - -(defn mssql - "Create a database specification for a mssql database. Opts should include keys - for :db, :user, and :password. You can also optionally set host and port." - [{:keys [user password db host port] - :or {user "dbuser", password "dbpassword", db "", host "localhost", port 1433} - :as opts}] - (merge {:classname "com.microsoft.sqlserver.jdbc.SQLServerDriver" ; must be in classpath - :subprotocol "sqlserver" - :subname (str "//" host ":" port ";database=" db ";user=" user ";password=" password)} - (dissoc opts :host :port :db))) - -(defn sqlite3 - "Create a database specification for a SQLite3 database. Opts should include a - key for :db which is the path to the database file." - [{:keys [db] - :or {db "sqlite.db"} - :as opts}] - (merge {:classname "org.sqlite.JDBC" ; must be in classpath - :subprotocol "sqlite" - :subname db} - (dissoc opts :db))) - -(defn oracle - "Create a database specification for an Oracle database. Opts should include keys - for :user and :password. You can also optionally set host and port." - [{:keys [host port] - :or {host "localhost", port 1521} - :as opts}] - (merge {:subprotocol "oracle:thin" - :subname (str "@" host ":" port)} - (dissoc opts :host :port))) diff --git a/src/metabase/driver/crate.clj b/src/metabase/driver/crate.clj index 00cef3a39a50eea126019b0060b006a3059e6731..6d88591a90c6febca681fce0017ddc09e696d769 100644 --- a/src/metabase/driver/crate.clj +++ b/src/metabase/driver/crate.clj @@ -40,16 +40,16 @@ (def ^:private ^:const now (hsql/call :current_timestamp 3)) -(defn- crate-spec +(defn- connection-details->spec [{:keys [hosts] - :as opts}] + :as details}] (merge {:classname "io.crate.client.jdbc.CrateDriver" ; must be in classpath :subprotocol "crate" :subname (str "//" hosts "/")} - (dissoc opts :hosts))) + (dissoc details :hosts))) (defn- can-connect? [details] - (let [connection-spec (crate-spec details)] + (let [connection-spec (connection-details->spec details)] (= 1 (first (vals (first (jdbc/query connection-spec ["select 1"]))))))) (defn- string-length-fn [field-key] @@ -71,7 +71,7 @@ :features (comp (u/rpartial disj :foreign-keys) sql/features)}) sql/ISQLDriver (merge (sql/ISQLDriverDefaultsMixin) - {:connection-details->spec (u/drop-first-arg crate-spec) + {:connection-details->spec (u/drop-first-arg connection-details->spec) :column->base-type (u/drop-first-arg column->base-type) :string-length-fn (u/drop-first-arg string-length-fn) :date crate-util/date diff --git a/src/metabase/driver/druid.clj b/src/metabase/driver/druid.clj index 6f652e66cf5b1a5e6bcb642b4b3c152275923c38..1cfe762537c4f60b095724370b24c44a2de2a3ef 100644 --- a/src/metabase/driver/druid.clj +++ b/src/metabase/driver/druid.clj @@ -7,6 +7,7 @@ [metabase.driver.druid.query-processor :as qp] (metabase.models [field :as field] [table :as table]) + [metabase.sync-database.analyze :as analyze] [metabase.util :as u])) ;;; ### Request helper fns @@ -138,6 +139,15 @@ (field-values-lazy-seq details table-name field-name total-items-fetched paging-identifiers))))))) +(defn- analyze-table + "Implementation of `analyze-table` for Druid driver." + [driver table new-table-ids] + ((analyze/make-analyze-table driver + :field-avg-length-fn (constantly 0) ; TODO implement this? + :field-percent-urls-fn (constantly 0) + :calculate-row-count? false) driver table new-table-ids)) + + ;;; ### DruidrDriver Class Definition (defrecord DruidDriver [] @@ -148,6 +158,7 @@ driver/IDriver (merge driver/IDriverDefaultsMixin {:can-connect? (u/drop-first-arg can-connect?) + :analyze-table analyze-table :describe-database (u/drop-first-arg describe-database) :describe-table (u/drop-first-arg describe-table) :details-fields (constantly [{:name "host" diff --git a/src/metabase/driver/druid/query_processor.clj b/src/metabase/driver/druid/query_processor.clj index 6b260f2a07866bb7790eef27f8c21963e505e974..7ec2887b4fc640deb8b0540d77b50901a5bac52c 100644 --- a/src/metabase/driver/druid/query_processor.clj +++ b/src/metabase/driver/druid/query_processor.clj @@ -57,8 +57,8 @@ Field (->rvalue [this] (:field-name this)) DateTimeField (->rvalue [this] (->rvalue (:field this))) Value (->rvalue [this] (:value this)) - DateTimeValue (->rvalue [{{unit :unit} :field, value :value}] (u/date->iso-8601 (u/date-trunc-or-extract unit value (get-timezone-id)))) - RelativeDateTimeValue (->rvalue [{:keys [unit amount]}] (u/date->iso-8601 (u/date-trunc-or-extract unit (u/relative-date unit amount) (get-timezone-id))))) + DateTimeValue (->rvalue [{{unit :unit} :field, value :value}] (u/date->iso-8601 (u/date-trunc unit value (get-timezone-id)))) + RelativeDateTimeValue (->rvalue [{:keys [unit amount]}] (u/date->iso-8601 (u/date-trunc unit (u/relative-date unit amount) (get-timezone-id))))) (defprotocol ^:private IDimensionOrMetric (^:private dimension-or-metric? [this] diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj index bd9610dc28500b1732fa2ec1d29ffa41fb663eed..4c1364d7ba01a6182f3faa329a72342280d94834 100644 --- a/src/metabase/driver/generic_sql.clj +++ b/src/metabase/driver/generic_sql.clj @@ -15,7 +15,7 @@ [metabase.sync-database.analyze :as analyze] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx]) - (:import java.sql.DatabaseMetaData + (:import (java.sql DatabaseMetaData ResultSet) java.util.Map (clojure.lang Keyword PersistentVector) com.mchange.v2.c3p0.ComboPooledDataSource @@ -327,7 +327,6 @@ ;;; ## Database introspection methods used by sync process -;; TODO - clojure.java.jdbc now ships with a `metadata-query` function we could use here. See #2918 (defmacro with-metadata "Execute BODY with `java.sql.DatabaseMetaData` for DATABASE." [[binding _ database] & body] @@ -335,6 +334,12 @@ (let [~binding (.getMetaData conn#)] ~@body))) +(defn- get-tables + "Fetch a JDBC Metadata ResultSet of tables in the DB, optionally limited to ones belonging to a given schema." + ^ResultSet [^DatabaseMetaData metadata, ^String schema-or-nil] + (jdbc/result-set-seq (.getTables metadata nil schema-or-nil "%" ; tablePattern "%" = match all tables + (into-array String ["TABLE", "VIEW", "FOREIGN TABLE", "MATERIALIZED VIEW"])))) + (defn fast-active-tables "Default, fast implementation of `ISQLDriver/active-tables` best suited for DBs with lots of system tables (like Oracle). Fetch list of schemas, then for each one not in `excluded-schemas`, fetch its Tables, and combine the results. @@ -344,7 +349,7 @@ (let [all-schemas (set (map :table_schem (jdbc/result-set-seq (.getSchemas metadata)))) schemas (set/difference all-schemas (excluded-schemas driver))] (set (for [schema schemas - table-name (mapv :table_name (jdbc/result-set-seq (.getTables metadata nil schema "%" (into-array String ["TABLE", "VIEW", "FOREIGN TABLE"]))))] ; tablePattern "%" = match all tables + table-name (mapv :table_name (get-tables metadata schema))] {:name table-name :schema schema})))) @@ -353,7 +358,7 @@ Fetch *all* Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side." [driver, ^DatabaseMetaData metadata] (set (for [table (filter #(not (contains? (excluded-schemas driver) (:table_schem %))) - (jdbc/result-set-seq (.getTables metadata nil nil "%" (into-array String ["TABLE", "VIEW", "FOREIGN TABLE"]))))] ; tablePattern "%" = match all tables + (get-tables metadata nil))] {:name (:table_name table) :schema (:table_schem table)}))) diff --git a/src/metabase/driver/oracle.clj b/src/metabase/driver/oracle.clj index 61e52998955f35a5e82992bdbaad185810d2fb5d..1780dfe544ab3b995dcecacdefe58addb23f01a9 100644 --- a/src/metabase/driver/oracle.clj +++ b/src/metabase/driver/oracle.clj @@ -7,7 +7,6 @@ [helpers :as h]) [metabase.config :as config] [toucan.db :as db] - [metabase.db.spec :as dbspec] [metabase.driver :as driver] [metabase.driver.generic-sql :as sql] [metabase.driver.generic-sql.query-processor :as sqlqp] @@ -42,8 +41,15 @@ [#"URI" :type/Text] [#"XML" :type/*]]) -(defn- connection-details->spec [{:keys [sid], :as details}] - (update (dbspec/oracle details) :subname (u/rpartial str \: sid))) +(defn- connection-details->spec + "Create a database specification for an Oracle database. DETAILS should include keys + for `:user`, `:password`, and `:sid`. You can also optionally set `:host` and `:port`." + [{:keys [host port sid] + :or {host "localhost", port 1521} + :as details}] + (merge {:subprotocol "oracle:thin" + :subname (str "@" host ":" port ":" sid)} + (dissoc details :host :port))) (defn- can-connect? [details] (let [connection (connection-details->spec details)] diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index 294ec44ee3c3119aa429fcbd8e22c2a0455cffcb..1e3c008d98d0ae41b3f60a92a8b710f7659f3fd7 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -180,23 +180,6 @@ (isa? base-type :type/IPAddress) (hx/cast :inet value) :else value))) - -(defn- materialized-views - "Fetch the Materialized Views for a Postgres DATABASE. - These are returned as a set of maps, the same format as `:tables` returned by `describe-database`." - [database] - (try (set (jdbc/query (sql/db->jdbc-connection-spec database) - ["SELECT schemaname AS \"schema\", matviewname AS \"name\" FROM pg_matviews;"])) - (catch Throwable e - (log/error "Failed to fetch materialized views for this database:" (.getMessage e))))) - -(defn- describe-database - "Custom implementation of `describe-database` for Postgres. - Postgres Materialized Views are not returned by normal JDBC methods: see [issue #2355](https://github.com/metabase/metabase/issues/2355); we have to manually fetch them. - This implementation combines the results from the generic SQL default implementation with materialized views fetched from `materialized-views`." - [driver database] - (update (sql/describe-database driver database) :tables (u/rpartial set/union (materialized-views database)))) - (defn- string-length-fn [field-key] (hsql/call :char_length (hx/cast :VARCHAR field-key))) @@ -221,7 +204,6 @@ driver/IDriver (merge (sql/IDriverSQLDefaultsMixin) {:date-interval (u/drop-first-arg date-interval) - :describe-database describe-database :details-fields (constantly [{:name "host" :display-name "Host" :default "localhost"} diff --git a/src/metabase/driver/sqlite.clj b/src/metabase/driver/sqlite.clj index 7f90d9b6dc1d4213fb196e9ee06958657e88e1d6..3aa4092e92257da90cef4539c703ef22842d6a76 100644 --- a/src/metabase/driver/sqlite.clj +++ b/src/metabase/driver/sqlite.clj @@ -4,12 +4,22 @@ (honeysql [core :as hsql] [format :as hformat]) [metabase.config :as config] - [metabase.db.spec :as dbspec] [metabase.driver :as driver] [metabase.driver.generic-sql :as sql] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx])) +(defn- connection-details->spec + "Create a database specification for a SQLite3 database. DETAILS should include a + key for `:db` which is the path to the database file." + [{:keys [db] + :or {db "sqlite.db"} + :as details}] + (merge {:classname "org.sqlite.JDBC" + :subprotocol "sqlite" + :subname db} + (dissoc details :db))) + ;; We'll do regex pattern matching here for determining Field types ;; because SQLite types can have optional lengths, e.g. NVARCHAR(100) or NUMERIC(10,5) ;; See also http://www.sqlite.org/datatype3.html @@ -158,7 +168,7 @@ (merge (sql/ISQLDriverDefaultsMixin) {:active-tables sql/post-filtered-active-tables :column->base-type (sql/pattern-based-column->base-type pattern->type) - :connection-details->spec (u/drop-first-arg dbspec/sqlite3) + :connection-details->spec (u/drop-first-arg connection-details->spec) :current-datetime-fn (constantly (hsql/call :datetime (hx/literal :now))) :date (u/drop-first-arg date) :prepare-sql-param (u/drop-first-arg prepare-sql-param) diff --git a/src/metabase/driver/sqlserver.clj b/src/metabase/driver/sqlserver.clj index 140691c758465b28ae3d38d97068452268dd4550..e92f37a9711495e412d990a3cfae1eed3b7a2ec9 100644 --- a/src/metabase/driver/sqlserver.clj +++ b/src/metabase/driver/sqlserver.clj @@ -1,7 +1,6 @@ (ns metabase.driver.sqlserver (:require [clojure.string :as s] [honeysql.core :as hsql] - [metabase.db.spec :as dbspec] [metabase.driver :as driver] [metabase.driver.generic-sql :as sql] [metabase.util :as u] @@ -48,27 +47,26 @@ :xml :type/* (keyword "int identity") :type/Integer} column-type)) ; auto-incrementing integer (ie pk) field -(defn- connection-details->spec [{:keys [domain instance ssl], :as details}] - (-> ;; Having the `:ssl` key present, even if it is `false`, will make the driver attempt to connect with SSL - (dbspec/mssql (if ssl - details - (dissoc details :ssl))) - ;; swap out Microsoft Driver details for jTDS ones - (assoc :classname "net.sourceforge.jtds.jdbc.Driver" - :subprotocol "jtds:sqlserver") - - ;; adjust the connection URL to match up with the jTDS format (see http://jtds.sourceforge.net/faq.html#urlFormat) - (update :subname (fn [subname] - ;; jTDS uses a "/" instead of ";database=" - (cond-> (s/replace subname #";database=" "/") - ;; and add the ;instance= option if applicable - (seq instance) (str ";instance=" instance) - - ;; add Windows domain for Windows domain authentication if applicable. useNTLMv2 = send LMv2/NTLMv2 responses when using Windows auth - (seq domain) (str ";domain=" domain ";useNTLMv2=true") - - ;; If SSL is specified append ;ssl=require, which enables SSL and throws exception if SSL connection cannot be made - ssl (str ";ssl=require")))))) + +(defn- connection-details->spec [{:keys [user password db host port instance domain ssl] + :or {user "dbuser", password "dbpassword", db "", host "localhost", port 1433} + :as details}] + {:classname "net.sourceforge.jtds.jdbc.Driver" + :subprotocol "jtds:sqlserver" + :loginTimeout 5 ; Wait up to 10 seconds for connection success. If we get no response by then, consider the connection failed + :subname (str "//" host ":" port "/" db) + ;; everything else gets passed as `java.util.Properties` to the JDBC connection. See full list of properties here: `http://jtds.sourceforge.net/faq.html#urlFormat` + ;; (passing these as Properties instead of part of the `:subname` is preferable because they support things like passwords with special characters) + :user user + :password password + :instance instance + :domain domain + :useNTLMv2 (boolean domain) ; if domain is specified, send LMv2/NTLMv2 responses when using Windows authentication + ;; for whatever reason `ssl=request` doesn't work with RDS (it hangs indefinitely), so just set ssl=off (disabled) if SSL isn't being used + :ssl (if ssl + "require" + "off")}) + (defn- date-part [unit expr] (hsql/call :datepart (hsql/raw (name unit)) expr)) diff --git a/src/metabase/driver/vertica.clj b/src/metabase/driver/vertica.clj index 53ae8dd76afa9ac2d85ba9dfc6e7a3b9f5b6abb2..675e815ff7aa97d1a1c88205ab561a3663638332 100644 --- a/src/metabase/driver/vertica.clj +++ b/src/metabase/driver/vertica.clj @@ -32,22 +32,13 @@ (keyword "Long Varchar") :type/Text (keyword "Long Varbinary") :type/*}) -(defn- vertica-spec [{:keys [host port db] - :or {host "localhost", port 5433, db ""} - :as opts}] +(defn- connection-details->spec [{:keys [host port db dbname] + :or {host "localhost", port 5433, db ""} + :as details}] (merge {:classname "com.vertica.jdbc.Driver" :subprotocol "vertica" - :subname (str "//" host ":" port "/" db)} - (dissoc opts :host :port :db :ssl))) - -(defn- connection-details->spec [details-map] - (-> details-map - (update :port (fn [port] - (if (string? port) - (Integer/parseInt port) - port))) - (rename-keys {:dbname :db}) - vertica-spec)) + :subname (str "//" host ":" port "/" (or dbname db))} + (dissoc details :host :port :dbname :db :ssl))) (defn- unix-timestamp->timestamp [expr seconds-or-milliseconds] (case seconds-or-milliseconds diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index 562d8710f7f769eee3a93ed31296e9862fb69d1f..2bcc5dc823eb681821e7a44cc0ddae629e6589f6 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -37,14 +37,6 @@ (def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} GET "Make a GET request to the Slack API." (partial do-slack-request http/get :query-params)) (def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} POST "Make a POST request to the Slack API." (partial do-slack-request http/post :form-params)) -(def ^:private ^{:arglists '([channel-id & {:as args}])} create-channel! - "Calls Slack api `channels.create` for CHANNEL." - (partial POST :channels.create, :name)) - -(def ^:private ^{:arglists '([channel-id & {:as args}])} archive-channel! - "Calls Slack api `channels.archive` for CHANNEL." - (partial POST :channels.archive, :channel)) - (def ^{:arglists '([& {:as args}])} channels-list "Calls Slack api `channels.list` function and returns the list of available channels." (comp :channels (partial GET :channels.list, :exclude_archived 1))) @@ -53,29 +45,26 @@ "Calls Slack api `users.list` function and returns the list of available users." (comp :members (partial GET :users.list))) -(defn- create-files-channel! - "Convenience function for creating our Metabase files channel to store file uploads." - [] - (when-let [{files-channel :channel, :as response} (create-channel! files-channel-name)] - (when-not files-channel - (log/error (u/pprint-to-str 'red response)) - (throw (ex-info "Error creating Slack channel for Metabase file uploads" response))) - ;; Right after creating our files channel, archive it. This is because we don't need users to see it. - (u/prog1 files-channel - (archive-channel! (:id <>))))) - -(defn- files-channel - "Return the `metabase_files` channel (as a map) if it exists." +(def ^:private ^:const ^String channel-missing-msg + (str "Slack channel named `metabase_files` is missing! Please create the channel in order to complete " + "the Slack integration. The channel is used for storing graphs that are included in pulses and " + "MetaBot answers.")) + +(defn- maybe-get-files-channel + "Return the `metabase_files channel (as a map) if it exists." [] (some (fn [channel] (when (= (:name channel) files-channel-name) channel)) (channels-list :exclude_archived 0))) -(defn get-or-create-files-channel! - "Calls Slack api `channels.info` and `channels.create` function as needed to ensure that a #metabase_files channel exists." +(defn files-channel + "Calls Slack api `channels.info` to check whether a channel named #metabase_files exists. If it doesn't, + throws an error that advices an admin to create it." [] - (or (files-channel) - (create-files-channel!))) + (or (maybe-get-files-channel) + (do (log/error (u/format-color 'red channel-missing-msg)) + (throw (ex-info channel-missing-msg {:status-code 400}))))) + (defn upload-file! "Calls Slack api `files.upload` function and returns the body of the uploaded file." diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index b1b99bd291c9f8c76b82de5c1a0890fd0de98c64..f460dac615cd142fa07cc597e4c9ec6ac8160221 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -9,6 +9,7 @@ [metabase.api.common :refer [*current-user* *current-user-id* *is-superuser?* *current-user-permissions-set*]] [metabase.api.common.internal :refer [*automatically-catch-api-exceptions*]] [metabase.config :as config] + [metabase.core.initialization-status :as init-status] [metabase.db :as mdb] (metabase.models [session :refer [Session]] [setting :refer [defsetting]] @@ -89,9 +90,7 @@ (defn- current-user-info-for-session "Return User ID and superuser status for Session with SESSION-ID if it is valid and not expired." [session-id] - (when (and session-id (or ((resolve 'metabase.core/initialized?)) - (println "Metabase is not initialized!") ; NOCOMMIT - )) + (when (and session-id (init-status/complete?)) (when-let [session (or (session-with-id session-id) (println "no matching session with ID") ; NOCOMMIT )] @@ -101,7 +100,7 @@ :is-superuser? (:is_superuser session)})))) (defn- add-current-user-info [{:keys [metabase-session-id], :as request}] - (when-not ((resolve 'metabase.core/initialized?)) + (when-not (init-status/complete?) (println "Metabase is not initialized yet!")) ; DEBUG (merge request (current-user-info-for-session metabase-session-id))) @@ -191,11 +190,9 @@ "https://www.google-analytics.com" ; Safari requires the protocol "https://*.googleapis.com" "*.gstatic.com" - "js.intercomcdn.com" - "*.intercom.io" (when config/is-dev? "localhost:8080")] - :frame-src ["'self'" + :child-src ["'self'" "https://accounts.google.com"] ; TODO - double check that we actually need this for Google Auth :style-src ["'unsafe-inline'" "'self'" @@ -206,11 +203,9 @@ (when config/is-dev? "localhost:8080")] :img-src ["*" - "self data:"] + "'self' data:"] :connect-src ["'self'" "metabase.us10.list-manage.com" - "*.intercom.io" - "wss://*.intercom.io" ; allow websockets as well (when config/is-dev? "localhost:8080 ws://localhost:8080")]}] (format "%s %s; " (name k) (apply str (interpose " " vs)))))}) @@ -261,10 +256,11 @@ "Middleware to set the `site-url` Setting if it's unset the first time a request is made." [handler] (fn [{{:strs [origin host] :as headers} :headers, :as request}] - (when-not (public-settings/site-url) - (when-let [site-url (or origin host)] - (log/info "Setting Metabase site URL to" site-url) - (public-settings/site-url site-url))) + (when (mdb/db-is-setup?) + (when-not (public-settings/site-url) + (when-let [site-url (or origin host)] + (log/info "Setting Metabase site URL to" site-url) + (public-settings/site-url site-url)))) (handler request))) diff --git a/src/metabase/models/field_values.clj b/src/metabase/models/field_values.clj index 8150b974998114bdb611d4ce429de1b395981465..2a8fcf8751e8d5c86c53eb517127e9ad2d35d6fd 100644 --- a/src/metabase/models/field_values.clj +++ b/src/metabase/models/field_values.clj @@ -54,15 +54,14 @@ {:pre [(integer? field-id) (field-should-have-field-values? field)]} (if-let [field-values (FieldValues :field_id field-id)] - (db/update! FieldValues (:id field-values) + (db/update! FieldValues (u/get-id field-values) :values ((resolve 'metabase.db.metadata-queries/field-distinct-values) field)) (create-field-values! field))) (defn create-field-values-if-needed! "Create `FieldValues` for a `Field` if they *should* exist but don't already exist. Returns the existing or newly created `FieldValues` for `Field`." - {:arglists '([field] - [field human-readable-values])} + {:arglists '([field] [field human-readable-values])} [{field-id :id :as field} & [human-readable-values]] {:pre [(integer? field-id)]} (when (field-should-have-field-values? field) @@ -72,10 +71,9 @@ (defn save-field-values! "Save the `FieldValues` for FIELD-ID, creating them if needed, otherwise updating them." [field-id values] - {:pre [(integer? field-id) - (coll? values)]} + {:pre [(integer? field-id) (coll? values)]} (if-let [field-values (FieldValues :field_id field-id)] - (db/update! FieldValues (:id field-values), :values values) + (db/update! FieldValues (u/get-id field-values), :values values) (db/insert! FieldValues :field_id field-id, :values values))) (defn clear-field-values! diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index e455e1f0666f7f25c5e55243aebfc036fa67754e..7993cd7b01c078e025da215fa932edc27a631f8f 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -168,7 +168,7 @@ `PulseCards`, `PulseChannels`, and `PulseChannelRecipients`. Returns the newly created `Pulse` or throws an Exception." - [pulse-name creator-id card-ids channels] + [pulse-name creator-id card-ids channels skip-if-empty?] {:pre [(string? pulse-name) (integer? creator-id) (sequential? card-ids) @@ -179,7 +179,8 @@ (db/transaction (let [{:keys [id] :as pulse} (db/insert! Pulse :creator_id creator-id - :name pulse-name)] + :name pulse-name + :skip_if_empty skip-if-empty?)] ;; add card-ids to the Pulse (update-pulse-cards! pulse card-ids) ;; add channels to the Pulse @@ -192,7 +193,7 @@ "Update an existing `Pulse`, including all associated data such as: `PulseCards`, `PulseChannels`, and `PulseChannelRecipients`. Returns the updated `Pulse` or throws an Exception." - [{:keys [id name cards channels] :as pulse}] + [{:keys [id name cards channels skip-if-empty?] :as pulse}] {:pre [(integer? id) (string? name) (sequential? cards) @@ -202,7 +203,7 @@ (every? map? channels)]} (db/transaction ;; update the pulse itself - (db/update! Pulse id, :name name) + (db/update! Pulse id, :name name, :skip_if_empty skip-if-empty?) ;; update cards (only if they changed) (when (not= cards (map :card_id (db/select [PulseCard :card_id], :pulse_id id, {:order-by [[:position :asc]]}))) (update-pulse-cards! pulse cards)) diff --git a/src/metabase/models/setting.clj b/src/metabase/models/setting.clj index 9595bc32802fcf6fea124874f14ce19f53667bd8..970f490a27efab049bce36401c38a49df17a718a 100644 --- a/src/metabase/models/setting.clj +++ b/src/metabase/models/setting.clj @@ -31,6 +31,7 @@ (setting/all)" (:refer-clojure :exclude [get]) (:require [clojure.string :as str] + [clojure.tools.logging :as log] [cheshire.core :as json] [environ.core :as env] [medley.core :as m] @@ -81,7 +82,7 @@ ;; Cache is a 1:1 mapping of what's in the DB ;; Cached lookup time is ~60µs, compared to ~1800µs for DB lookup -(def ^:private cache +(defonce ^:private cache (atom nil)) (defn- restore-cache-if-needed! [] @@ -170,6 +171,24 @@ ;;; ------------------------------------------------------------ set! ------------------------------------------------------------ +(defn- update-setting! [setting-name new-value] + (db/update-where! Setting {:key setting-name} + :value new-value)) + +(defn- set-new-setting! + "Insert a new row for a Setting with SETTING-NAME and SETTING-VALUE. + Takes care of resetting the cache if the insert fails for some reason." + [setting-name new-value] + (try (db/insert! Setting + :key setting-name + :value new-value) + ;; if for some reason inserting the new value fails it almost certainly means the cache is out of date + ;; and there's actually a row in the DB that's not in the cache for some reason. Go ahead and update the + ;; existing value and log a warning + (catch Throwable e + (log/warn "Error INSERTing a new Setting:" (.getMessage e) "\nAssuming Setting already exists in DB and updating existing value.") + (update-setting! setting-name new-value)))) + (s/defn ^:always-validate set-string! "Set string value of SETTING-OR-NAME. A `nil` or empty NEW-VALUE can be passed to unset (i.e., delete) SETTING-OR-NAME." [setting-or-name, new-value :- (s/maybe s/Str)] @@ -182,12 +201,9 @@ (cond (not new-value) (db/simple-delete! Setting :key setting-name) ;; if there's a value in the cache then the row already exists in the DB; update that - (contains? @cache setting-name) (db/update-where! Setting {:key setting-name} - :value new-value) + (contains? @cache setting-name) (update-setting! setting-name new-value) ;; if there's nothing in the cache then the row doesn't exist, insert a new one - :else (db/insert! Setting - :key setting-name - :value new-value)) + :else (set-new-setting! setting-name new-value)) ;; update cached value (if new-value (swap! cache assoc setting-name new-value) diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index 35f5ae33027abfff85425f7d959934a8c7c62b29..107170404c3abdb8e97b213e3541995cc9523398 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -23,11 +23,13 @@ :default "Metabase") ;; This value is *guaranteed* to never have a trailing slash :D +;; It will also prepend `http://` to the URL if there's not protocol when it comes in (defsetting site-url "The base URL of this Metabase instance, e.g. \"http://metabase.my-company.com\"." :setter (fn [new-value] (setting/set-string! :site-url (when new-value - (s/replace new-value #"/$" ""))))) + (cond->> (s/replace new-value #"/$" "") + (not (s/starts-with? new-value "http")) (str "http://")))))) (defsetting admin-email "The email address users should be referred to if they encounter a problem.") diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index adff446e58607c1b5afa13322bd88dab5a89c1b7..2a1b81b901430bd397af93f019551c1daccdaefc 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -46,7 +46,7 @@ (defn create-and-upload-slack-attachments! "Create an attachment in Slack for a given Card by rendering its result into an image and uploading it." [card-results] - (when-let [{channel-id :id} (slack/get-or-create-files-channel!)] + (let [{channel-id :id} (slack/files-channel)] (doall (for [{{card-id :id, card-name :name, :as card} :card, result :result} card-results] (let [image-byte-array (render/render-pulse-card-to-png card result) slack-file-url (slack/upload-file! image-byte-array "image.png" channel-id)] @@ -65,6 +65,20 @@ (str "Pulse: " (:name pulse)) attachments))) +(defn- is-card-empty? + "Check if the card is empty" + [card] + (let [result (:result card)] + (or (zero? (-> result :row_count)) + ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters + (= [[nil]] + (-> result :data :rows))))) + +(defn- are-all-cards-empty? + "Do none of the cards have any results?" + [results] + (every? is-card-empty? results)) + (defn send-pulse! "Execute and Send a `Pulse`, optionally specifying the specific `PulseChannels`. This includes running each `PulseCard`, formatting the results, and sending the results to any specified destination. @@ -77,9 +91,10 @@ (let [results (for [card cards] (execute-card (:id card), :pulse-id (:id pulse))) ; Pulse ID may be `nil` if the Pulse isn't saved yet channel-ids (or channel-ids (mapv :id (:channels pulse)))] - (doseq [channel-id channel-ids] - (let [{:keys [channel_type details recipients]} (some #(when (= channel-id (:id %)) %) - (:channels pulse))] - (condp = (keyword channel_type) - :email (send-email-pulse! pulse results recipients) - :slack (send-slack-pulse! pulse results (:channel details))))))) + (when-not (and (:skip_if_empty pulse) (are-all-cards-empty? results)) + (doseq [channel-id channel-ids] + (let [{:keys [channel_type details recipients]} (some #(when (= channel-id (:id %)) %) + (:channels pulse))] + (condp = (keyword channel_type) + :email (send-email-pulse! pulse results recipients) + :slack (send-slack-pulse! pulse results (:channel details)))))))) diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index e988b311e61bcd4b7403247320d4e87d72359fc1..26dadd4ce6293e17b1064eaadedf3b5ad6531bf8 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -390,7 +390,9 @@ (cond (or (= aggregation :rows) (contains? #{:pin_map :state :country} (:display card))) nil - (zero? row-count) :empty + (or (zero? row-count) + ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters + (= [[nil]] (-> data :rows))) :empty (and (= col-count 1) (= row-count 1)) :scalar (and (= col-count 2) diff --git a/src/metabase/query_processor/expand.clj b/src/metabase/query_processor/expand.clj index 0ec8e1e48a732c562de56d506f048fc4a611169d..23f660f02db5240fca2b0d47853ddb76de327e35 100644 --- a/src/metabase/query_processor/expand.clj +++ b/src/metabase/query_processor/expand.clj @@ -109,7 +109,7 @@ (relative-datetime -31 :day)" ([n] (s/validate (s/eq :current) (normalize-token n)) (relative-datetime 0 nil)) - ([n :- s/Int, unit] (i/map->RelativeDatetime {:amount n, :unit (if (zero? n) + ([n :- s/Int, unit] (i/map->RelativeDatetime {:amount n, :unit (if (nil? unit) :day ; give :unit a default value so we can simplify the schema a bit and require a :unit (normalize-token unit))}))) @@ -307,7 +307,7 @@ :next (recur f 1 unit)) (let [f (datetime-field f unit)] (cond - (core/= n 0) (= f (value f (relative-datetime :current))) + (core/= n 0) (= f (value f (relative-datetime 0 unit))) (core/= n -1) (= f (value f (relative-datetime -1 unit))) (core/= n 1) (= f (value f (relative-datetime 1 unit))) (core/< n -1) (between f (value f (relative-datetime n unit)) diff --git a/src/metabase/query_processor/parameters.clj b/src/metabase/query_processor/parameters.clj index af2793c722d002bb37962d3773ac7966516f9e64..d3ac5416c555c4f1ef0f06ce0451bba6d7a177ce 100644 --- a/src/metabase/query_processor/parameters.clj +++ b/src/metabase/query_processor/parameters.clj @@ -10,18 +10,39 @@ [metabase.util :as u]) (:import (org.joda.time DateTimeConstants DateTime))) +;;; +-------------------------------------------------------------------------------------------------------+ +;;; | DATE RANGES & PERIODS | +;;; +-------------------------------------------------------------------------------------------------------+ + +;; Both in MBQL and SQL parameter substitution a field value is compared to a date range, either relative or absolute. +;; Currently the field value is casted to a day (ignoring the time of day), so the ranges should have the same +;; granularity level. +;; +;; See https://github.com/metabase/metabase/pull/4607#issuecomment-290884313 how we could support +;; hour/minute granularity in field parameter queries. + + +(defn- day-range + [^DateTime start, ^DateTime end] + {:end end + :start start}) + +(defn- week-range + [^DateTime start, ^DateTime end] + ;; weeks always start on SUNDAY and end on SATURDAY + ;; NOTE: in Joda the week starts on Monday and ends on Sunday, so to get the right Sunday we rollback 1 week + {:end (.withDayOfWeek end DateTimeConstants/SATURDAY) + :start (.withDayOfWeek ^DateTime (t/minus start (t/weeks 1)) DateTimeConstants/SUNDAY)}) + +(defn- month-range + [^DateTime start, ^DateTime end] + {:end (t/last-day-of-the-month end) + :start (t/first-day-of-the-month start)}) -(def ^:private ^:const relative-dates - #{"today" - "yesterday" - "past7days" - "past30days" - "thisweek" - "thismonth" - "thisyear" - "lastweek" - "lastmonth" - "lastyear"}) +(defn- year-range + [^DateTime start, ^DateTime end] + {:end (t/last-day-of-the-month (.withMonthOfYear end DateTimeConstants/DECEMBER)) + :start (t/first-day-of-the-month (.withMonthOfYear start DateTimeConstants/JANUARY))}) (defn- start-of-quarter [quarter year] (t/first-day-of-the-month (.withMonthOfYear (t/date-time year) (case quarter @@ -29,84 +50,179 @@ "Q2" DateTimeConstants/APRIL "Q3" DateTimeConstants/JULY "Q4" DateTimeConstants/OCTOBER)))) +(defn- quarter-range + [quarter year] + (let [dt (start-of-quarter quarter year)] + {:end (t/last-day-of-the-month (t/plus dt (t/months 2))) + :start (t/first-day-of-the-month dt)})) + +(def ^:private operations-by-date-unit + {"day" {:unit-range day-range + :to-period t/days} + "week" {:unit-range week-range + :to-period t/weeks} + "month" {:unit-range month-range + :to-period t/months} + "year" {:unit-range year-range + :to-period t/years}}) + +(defn- parse-absolute-date + [date] + (tf/parse (tf/formatters :date-opt-time) date)) + +;;; +-------------------------------------------------------------------------------------------------------+ +;;; | DATE STRING DECODERS | +;;; +-------------------------------------------------------------------------------------------------------+ + +;; For parsing date strings and producing either a date range (for raw SQL parameter substitution) or a MBQL clause + +(defn- expand-parser-groups + [group-label group-value] + (case group-label + :unit (conj (seq (get operations-by-date-unit group-value)) + [group-label group-value]) + :int-value [[group-label (Integer/parseInt group-value)]] + (:date :date-1 :date-2) [[group-label (parse-absolute-date group-value)]] + [[group-label group-value]])) -(defn- week-range [^DateTime dt] - ;; weeks always start on SUNDAY and end on SATURDAY - ;; NOTE: in Joda the week starts on Monday and ends on Sunday, so to get the right Sunday we rollback 1 week - {:end (.withDayOfWeek dt DateTimeConstants/SATURDAY) - :start (.withDayOfWeek ^DateTime (t/minus dt (t/weeks 1)) DateTimeConstants/SUNDAY)}) - -(defn- month-range [^DateTime dt] - {:end (t/last-day-of-the-month dt) - :start (t/first-day-of-the-month dt)}) - -;; NOTE: this is perhaps a little hacky, but we are assuming that `dt` will be in the first month of the quarter -(defn- quarter-range [^DateTime dt] - {:end (t/last-day-of-the-month (t/plus dt (t/months 2))) - :start (t/first-day-of-the-month dt)}) - -(defn- year-range [^DateTime dt] - {:end (t/last-day-of-the-month (.withMonthOfYear dt DateTimeConstants/DECEMBER)) - :start (t/first-day-of-the-month (.withMonthOfYear dt DateTimeConstants/JANUARY))}) - -(defn- absolute-date->range - "Take a given string description of an absolute date range and return a MAP with a given `:start` and `:end`. - - Supported formats: - - \"2014-05-10~2014-05-16\" - \"Q1-2016\" - \"2016-04\" - \"2016-04-12\"" - [value] - (if (s/includes? value "~") - ;; these values are already expected to be iso8601 strings, so we are done - (zipmap [:start :end] (s/split value #"~" 2)) - ;; these cases represent fixed date ranges, but we need to calculate start/end still - (->> (cond - ;; quarter-year (Q1-2016) - (s/starts-with? value "Q") (let [[quarter year] (s/split value #"-" 2)] - (quarter-range (start-of-quarter quarter (Integer/parseInt year)))) - ;; year-month (2016-04) - (= (count value) 7) (month-range (tf/parse (tf/formatters :year-month) value)) - ;; default is to assume a single day (2016-04-18). we still parse just to validate. - :else (let [dt (tf/parse (tf/formatters :year-month-day) value)] - {:start dt, :end dt})) - (m/map-vals (partial tf/unparse (tf/formatters :year-month-day)))))) - - -(defn- relative-date->range - "Take a given string description of a relative date range such as 'lastmonth' and return a MAP with a given - `:start` and `:end` as iso8601 string formatted dates. Values should be appropriate for the given REPORT-TIMEZONE." - [value report-timezone] - (let [tz (t/time-zone-for-id report-timezone) - formatter (tf/formatter "yyyy-MM-dd" tz) - today (.withTimeAtStartOfDay (t/to-time-zone (t/now) tz))] - (->> (case value - "past7days" {:end (t/minus today (t/days 1)) - :start (t/minus today (t/days 7))} - "past30days" {:end (t/minus today (t/days 1)) - :start (t/minus today (t/days 30))} - "thisweek" (week-range today) - "thismonth" (month-range today) - "thisyear" (year-range today) - "lastweek" (week-range (t/minus today (t/weeks 1))) - "lastmonth" (month-range (t/minus today (t/months 1))) - "lastyear" (year-range (t/minus today (t/years 1))) - "yesterday" {:end (t/minus today (t/days 1)) - :start (t/minus today (t/days 1))} - "today" {:end today - :start today}) - ;; the above values are JodaTime objects, so unparse them to iso8601 strings - (m/map-vals (partial tf/unparse formatter))))) - -(defn date->range - "Convert a relative or absolute date range VALUE to a map with `:start` and `:end` keys." - [value report-timezone] - (if (contains? relative-dates value) - (relative-date->range value report-timezone) - (absolute-date->range value))) +(defn- regex->parser + "Takes a regex and labels matching the regex capturing groups. Returns a parser which + takes a parameter value, validates the value against regex and gives a map of labels + and group values. Respects the following special label names: + :unit – finds a matching date unit and merges date unit operations to the result + :int-value – converts the group value to integer + :date, :date1, date2 – converts the group value to absolute date" + [regex group-labels] + (fn [param-value] + (when-let [regex-result (re-matches regex param-value)] + (into {} (mapcat expand-parser-groups group-labels (rest regex-result)))))) +;; Decorders consist of: +;; 1) Parser which tries to parse the date parameter string +;; 2) Range decoder which takes the parser output and produces a date range relative to the given datetime +;; 3) Filter decoder which takes the parser output and produces a mbql clause for a given mbql field reference + +(def ^:private relative-date-string-decoders + [{:parser #(= % "today") + :range (fn [_ dt] + {:start dt, + :end dt}) + :filter (fn [_ field] ["=" field ["relative_datetime" "current"]])} + + {:parser #(= % "yesterday") + :range (fn [_ dt] + {:start (t/minus dt (t/days 1)) + :end (t/minus dt (t/days 1))}) + :filter (fn [_ field] ["=" field ["relative_datetime" -1 "day"]])} + + {:parser (regex->parser #"past([0-9]+)(day|week|month|year)s", [:int-value :unit]) + :range (fn [{:keys [unit int-value unit-range to-period]} dt] + (unit-range (t/minus dt (to-period int-value)) + (t/minus dt (to-period 1)))) + :filter (fn [{:keys [unit int-value]} field] + ["TIME_INTERVAL" field (- int-value) unit])} + + {:parser (regex->parser #"next([0-9]+)(day|week|month|year)s" [:int-value :unit]) + :range (fn [{:keys [unit int-value unit-range to-period]} dt] + (unit-range (t/plus dt (to-period 1)) + (t/plus dt (to-period int-value)))) + :filter (fn [{:keys [unit int-value]} field] + ["TIME_INTERVAL" field int-value unit])} + + {:parser (regex->parser #"last(day|week|month|year)" [:unit]) + :range (fn [{:keys [unit-range to-period]} dt] + (let [last-unit (t/minus dt (to-period 1))] + (unit-range last-unit last-unit))) + :filter (fn [{:keys [unit]} field] + ["TIME_INTERVAL" field "last" unit])} + + {:parser (regex->parser #"this(day|week|month|year)" [:unit]) + :range (fn [{:keys [unit-range]} dt] + (unit-range dt dt)) + :filter (fn [{:keys [unit]} field] + ["TIME_INTERVAL" field "current" unit])}]) + +(defn- day->iso8601 [date] + (tf/unparse (tf/formatters :year-month-day) date)) + +(defn- range->filter + [{:keys [start end]} field] + ["BETWEEN" field (day->iso8601 start) (day->iso8601 end)]) + +(def ^:private absolute-date-string-decoders + ;; year and month + [{:parser (regex->parser #"([0-9]{4}-[0-9]{2})" [:date]) + :range (fn [{:keys [date]} _] + (month-range date date)) + :filter (fn [{:keys [date]} field] + (range->filter (month-range date date) field))} + ;; quarter year + {:parser (regex->parser #"(Q[1-4]{1})-([0-9]{4})" [:quarter :year]) + :range (fn [{:keys [quarter year]} _] + (quarter-range quarter (Integer/parseInt year))) + :filter (fn [{:keys [quarter year]} field] + (range->filter (quarter-range quarter (Integer/parseInt year)) + field))} + ;; single day + {:parser (regex->parser #"([0-9-T:]+)" [:date]) + :range (fn [{:keys [date]} _] + {:start date, :end date}) + :filter (fn [{:keys [date]} field] + (let [iso8601date (day->iso8601 date)] + ["BETWEEN" field iso8601date iso8601date]))} + ;; day range + {:parser (regex->parser #"([0-9-T:]+)~([0-9-T:]+)" [:date-1 :date-2]) + :range (fn [{:keys [date-1 date-2]} _] + {:start date-1, :end date-2}) + :filter (fn [{:keys [date-1 date-2]} field] + ["BETWEEN" field (day->iso8601 date-1) (day->iso8601 date-2)])} + ;; before day + {:parser (regex->parser #"~([0-9-T:]+)" [:date]) + :range (fn [{:keys [date]} _] + {:end date}) + :filter (fn [{:keys [date]} field] + ["<" field (day->iso8601 date)])} + ;; after day + {:parser (regex->parser #"([0-9-T:]+)~" [:date]) + :range (fn [{:keys [date]} _] + {:start date}) + :filter (fn [{:keys [date]} field] + [">" field (day->iso8601 date)])}]) + +(def ^:private all-date-string-decoders + (concat relative-date-string-decoders absolute-date-string-decoders)) + +(defn- execute-decoders + "Returns the first successfully decoded value, run through both + parser and a range/filter decoder depending on `decoder-type`." + [decoders decoder-type decoder-param date-string] + (some (fn [{parser :parser, parser-result-decoder decoder-type}] + (when-let [parser-result (parser date-string)] + (parser-result-decoder parser-result decoder-param))) + decoders)) + +(defn date-string->range + "Takes a string description of a date range such as 'lastmonth' or '2016-07-15~2016-08-6' and + return a MAP with `:start` and `:end` as iso8601 string formatted dates, respecting the given timezone." + [date-string report-timezone] + (let [tz (t/time-zone-for-id report-timezone) + formatter-local-tz (tf/formatter "yyyy-MM-dd" tz) + formatter-no-tz (tf/formatter "yyyy-MM-dd") + today (.withTimeAtStartOfDay (t/to-time-zone (t/now) tz))] + ;; Relative dates respect the given time zone because a notion like "last 7 days" might mean a different range of days + ;; depending on the user timezone + (or (->> (execute-decoders relative-date-string-decoders :range today date-string) + (m/map-vals (partial tf/unparse formatter-local-tz))) + ;; Absolute date ranges don't need the time zone conversion because in SQL the date ranges are compared against + ;; the db field value that is casted granularity level of a day in the db time zone + (->> (execute-decoders absolute-date-string-decoders :range nil date-string) + (m/map-vals (partial tf/unparse formatter-no-tz)))))) + +(defn- date-string->filter + "Takes a string description of a date range such as 'lastmonth' or '2016-07-15~2016-08-6' and returns a + corresponding MBQL filter clause for a given field reference." + [date-string field-reference] + (execute-decoders all-date-string-decoders :filter field-reference date-string)) ;;; +-------------------------------------------------------------------------------------------------------+ ;;; | MBQL QUERIES | @@ -125,27 +241,13 @@ ;; otherwise convert to a Long :else (Long/parseLong param-value))) - (defn- build-filter-clause [{param-type :type, param-value :value, [_ field] :target}] (let [param-value (parse-param-value-for-type param-type param-value)] (cond ;; default behavior (non-date filtering) is to use a simple equals filter (not (s/starts-with? param-type "date")) ["=" field param-value] - ;; relative date range - (contains? relative-dates param-value) (case param-value - "past7days" ["TIME_INTERVAL" field -7 "day"] - "past30days" ["TIME_INTERVAL" field -30 "day"] - "thisweek" ["TIME_INTERVAL" field "current" "week"] - "thismonth" ["TIME_INTERVAL" field "current" "month"] - "thisyear" ["TIME_INTERVAL" field "current" "year"] - "lastweek" ["TIME_INTERVAL" field "last" "week"] - "lastmonth" ["TIME_INTERVAL" field "last" "month"] - "lastyear" ["TIME_INTERVAL" field "last" "year"] - "yesterday" ["=" field ["relative_datetime" -1 "day"]] - "today" ["=" field ["relative_datetime" "current"]]) - ;; absolute date range - :else (let [{:keys [start end]} (absolute-date->range param-value)] - ["BETWEEN" field start end])))) + ;; date range + :else (date-string->filter param-value field)))) (defn- merge-filter-clauses [base addtl] (cond diff --git a/src/metabase/query_processor/sql_parameters.clj b/src/metabase/query_processor/sql_parameters.clj index 1ebe0d74917d9aebd261e12d241019119fad350f..a48cb7b71d810e10af732b03f382f4a5c1462b52 100644 --- a/src/metabase/query_processor/sql_parameters.clj +++ b/src/metabase/query_processor/sql_parameters.clj @@ -58,13 +58,14 @@ ;; TAGS in this case are simple params like {{x}} that get replaced with a single value ("ABC" or 1) as opposed to a "FieldFilter" clause like Dimensions (def ^:private TagParam "Schema for values passed in as part of the `:template_tags` list." - {(s/optional-key :id) su/NonBlankString ; this is used internally by the frontend - :name su/NonBlankString - :display_name su/NonBlankString - :type (s/enum "number" "dimension" "text" "date") - (s/optional-key :dimension) [s/Any] - (s/optional-key :required) s/Bool - (s/optional-key :default) s/Any}) + {(s/optional-key :id) su/NonBlankString ; this is used internally by the frontend + :name su/NonBlankString + :display_name su/NonBlankString + :type (s/enum "number" "dimension" "text" "date") + (s/optional-key :dimension) [s/Any] + (s/optional-key :widget_type) su/NonBlankString + (s/optional-key :required) s/Bool + (s/optional-key :default) s/Any}) (def ^:private DimensionValue {:type su/NonBlankString @@ -198,13 +199,13 @@ (->replacement-snippet-info \"ABC\") -> {:replacement-snippet \"?\", :prepared-statement-args \"ABC\"}")) -(defn- relative-date-param-type? [param-type] (contains? #{"date/range" "date/month-year" "date/quarter-year" "date/relative"} param-type)) +(defn- relative-date-param-type? [param-type] (contains? #{"date/range" "date/month-year" "date/quarter-year" "date/relative" "date/all-options"} param-type)) (defn- date-param-type? [param-type] (str/starts-with? param-type "date/")) ;; for relative dates convert the param to a `DateRange` record type and call `->replacement-snippet-info` on it (s/defn ^:private ^:always-validate relative-date-dimension-value->replacement-snippet-info :- ParamSnippetInfo [value] - (->replacement-snippet-info (map->DateRange ((resolve 'metabase.query-processor.parameters/date->range) value *timezone*)))) ; TODO - get timezone from query dict + (->replacement-snippet-info (map->DateRange ((resolve 'metabase.query-processor.parameters/date-string->range) value *timezone*)))) ; TODO - get timezone from query dict (s/defn ^:private ^:always-validate dimension-value->equals-clause-sql :- ParamSnippetInfo [value] @@ -242,13 +243,13 @@ :prepared-statement-args (reduce concat (map :prepared-statement-args replacement-snippet-maps))}) (extend-protocol ISQLParamSubstituion - nil (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Object (->replacement-snippet-info [this] (honeysql->replacement-snippet-info (str this))) - Number (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Boolean (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Keyword (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - SqlCall (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - NoValue (->replacement-snippet-info [_] {:replacement-snippet ""}) + nil (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Object (->replacement-snippet-info [this] (honeysql->replacement-snippet-info (str this))) + Number (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Boolean (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Keyword (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + SqlCall (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + NoValue (->replacement-snippet-info [_] {:replacement-snippet ""}) Date (->replacement-snippet-info [{:keys [s]}] @@ -256,9 +257,11 @@ DateRange (->replacement-snippet-info [{:keys [start end]}] - (if (= start end) - {:replacement-snippet "= ?", :prepared-statement-args [(u/->Timestamp start)]} - {:replacement-snippet "BETWEEN ? AND ?", :prepared-statement-args [(u/->Timestamp start) (u/->Timestamp end)]})) + (cond + (= start end) {:replacement-snippet "= ?", :prepared-statement-args [(u/->Timestamp start)]} + (nil? start) {:replacement-snippet "< ?", :prepared-statement-args [(u/->Timestamp end)]} + (nil? end) {:replacement-snippet "> ?", :prepared-statement-args [(u/->Timestamp start)]} + :else {:replacement-snippet "BETWEEN ? AND ?", :prepared-statement-args [(u/->Timestamp start) (u/->Timestamp end)]})) ;; TODO - clean this up if possible! Dimension @@ -342,19 +345,25 @@ (s/defn ^:private ^:always-validate handle-optional-snippet :- ParamSnippetInfo "Create the approprate `:replacement-snippet` for PARAM, combining the value of REPLACEMENT-SNIPPET from the Param->SQL Substitution phase with the OPTIONAL-SNIPPET, if any." - [{:keys [variable-snippet optional-snippet replacement-snippet], :as snippet-info} :- ParamSnippetInfo] + [{:keys [variable-snippet optional-snippet replacement-snippet prepared-statement-args], :as snippet-info} :- ParamSnippetInfo] (assoc snippet-info - :replacement-snippet (cond - (not optional-snippet) replacement-snippet ; if there is no optional-snippet return replacement as-is - (seq replacement-snippet) (str/replace optional-snippet variable-snippet replacement-snippet) ; if replacement-snippet is non blank splice into optional-snippet - :else ""))) ; otherwise return blank replacement (i.e. for NoValue) + :replacement-snippet (cond + (not optional-snippet) replacement-snippet ; if there is no optional-snippet return replacement as-is + (seq replacement-snippet) (str/replace optional-snippet variable-snippet replacement-snippet) ; if replacement-snippet is non blank splice into optional-snippet + :else "") ; otherwise return blank replacement (i.e. for NoValue) + ;; for every thime the `variable-snippet` occurs in the `optional-snippet` we need to supply an additional set of `prepared-statment-args` + ;; e.g. [[ AND ID = {{id}} OR USER_ID = {{id}} ]] should have *2* sets of the prepared statement args for {{id}} since it occurs twice + :prepared-statement-args (if-let [occurances (u/occurances-of-substring optional-snippet variable-snippet)] + (apply concat (repeat occurances prepared-statement-args)) + prepared-statement-args))) (s/defn ^:private ^:always-validate add-replacement-snippet-info :- [ParamSnippetInfo] "Add `:replacement-snippet` and `:prepared-statement-args` info to the maps in PARAMS-SNIPPETS-INFO by looking at PARAM-KEY->VALUE and using the Param->SQL substituion functions." [params-snippets-info :- [ParamSnippetInfo], param-key->value :- ParamValues] (for [snippet-info params-snippets-info] - (handle-optional-snippet (merge snippet-info (s/validate ParamSnippetInfo (->replacement-snippet-info (snippet-value snippet-info param-key->value))))))) + (handle-optional-snippet (merge snippet-info + (s/validate ParamSnippetInfo (->replacement-snippet-info (snippet-value snippet-info param-key->value))))))) diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index f1008afd248e117b8cc67c74f3ef4b838b456403..5bca43914c31490ed907fa9a3cb8607b26557da9 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -6,13 +6,14 @@ [ring.util.response :as resp] [stencil.core :as stencil] [metabase.api.routes :as api] + [metabase.core.initialization-status :as init-status] [metabase.public-settings :as public-settings] [metabase.util :as u] [metabase.util.embed :as embed])) (defn- entrypoint [entry embeddable? {:keys [uri]}] - (-> (if ((resolve 'metabase.core/initialized?)) + (-> (if (init-status/complete?) (stencil/render-string (slurp (or (io/resource (str "frontend_client/" entry ".html")) (throw (Exception. (str "Cannot find './resources/frontend_client/" entry ".html'. Did you remember to build the Metabase frontend?"))))) {:bootstrap_json (json/generate-string (public-settings/public-settings)) @@ -40,9 +41,17 @@ ;; ^/$ -> index.html (GET "/" [] index) (GET "/favicon.ico" [] (resp/resource-response "frontend_client/favicon.ico")) - ;; ^/api/ -> API routes - (context "/api" [] api/routes) - ; ^/app/ -> static files under frontend_client/app + ;; ^/api/health -> Health Check Endpoint + (GET "/api/health" [] (if (init-status/complete?) + {:status 200, :body {:status "ok"}} + {:status 503, :body {:status "initializing", :progress (init-status/progress)}})) + ;; ^/api/ -> All other API routes + (context "/api" [] (fn [& args] + ;; if Metabase is not finished initializing, return a generic error message rather than something potentially confusing like "DB is not set up" + (if-not (init-status/complete?) + {:status 503, :body "Metabase is still initializing. Please sit tight..."} + (apply api/routes args)))) + ;; ^/app/ -> static files under frontend_client/app (context "/app" [] (route/resources "/" {:root "frontend_client/app"}) ;; return 404 for anything else starting with ^/app/ that doesn't exist diff --git a/src/metabase/sync_database/analyze.clj b/src/metabase/sync_database/analyze.clj index 35cee5ee35e9565ecc50d425d62e49982cc532b9..bad37a0c7a53b7e267668e923e7fc08daa921f1b 100644 --- a/src/metabase/sync_database/analyze.clj +++ b/src/metabase/sync_database/analyze.clj @@ -14,21 +14,23 @@ [metabase.sync-database.interface :as i] [metabase.util :as u])) -(def ^:private ^:const percent-valid-url-threshold +(def ^:private ^:const ^Float percent-valid-url-threshold "Fields that have at least this percent of values that are valid URLs should be given a special type of `:type/URL`." 0.95) - -(def ^:private ^:const low-cardinality-threshold +(def ^:private ^:const ^Integer low-cardinality-threshold "Fields with less than this many distinct values should automatically be given a special type of `:type/Category`." 300) -(def ^:private ^:const field-values-entry-max-length +(def ^:private ^:const ^Integer field-values-entry-max-length "The maximum character length for a stored `FieldValues` entry." 100) +(def ^:private ^:const ^Integer field-values-total-max-length + "Maximum total length for a FieldValues entry (combined length of all values for the field)." + (* low-cardinality-threshold field-values-entry-max-length)) -(def ^:private ^:const average-length-no-preview-threshold +(def ^:private ^:const ^Integer average-length-no-preview-threshold "Fields whose values' average length is greater than this amount should be marked as `preview_display = false`." 50) @@ -52,6 +54,12 @@ (not (isa? (:base_type field) :type/Collection)) (not (= (:base_type field) :type/*))))) +(defn- field-values-below-low-cardinality-threshold? [non-nil-values] + (and (<= (count non-nil-values) low-cardinality-threshold) + ;; very simple check to see if total length of field-values exceeds (total values * max per value) + (let [total-length (reduce + (map (comp count str) non-nil-values))] + (<= total-length field-values-total-max-length)))) + (defn test:cardinality-and-extract-field-values "Extract field-values for FIELD. If number of values exceeds `low-cardinality-threshold` then we return an empty set of values." [field field-stats] @@ -59,10 +67,7 @@ ;; for example, :type/Category fields with more than MAX values don't need to be rescanned all the time (let [non-nil-values (filter identity (queries/field-distinct-values field (inc low-cardinality-threshold))) ;; only return the list if we didn't exceed our MAX values and if the the total character count of our values is reasable (#2332) - distinct-values (when-not (or (< low-cardinality-threshold (count non-nil-values)) - ;; very simple check to see if total length of field-values exceeds (total values * max per value) - (< (* low-cardinality-threshold - field-values-entry-max-length) (reduce + (map (comp count str) non-nil-values)))) + distinct-values (when (field-values-below-low-cardinality-threshold? non-nil-values) non-nil-values)] (cond-> (assoc field-stats :values distinct-values) (and (nil? (:special_type field)) @@ -181,12 +186,13 @@ (defn make-analyze-table "Make a generic implementation of `analyze-table`." {:style/indent 1} - [driver & {:keys [field-avg-length-fn field-percent-urls-fn] + [driver & {:keys [field-avg-length-fn field-percent-urls-fn calculate-row-count?] :or {field-avg-length-fn (partial driver/default-field-avg-length driver) - field-percent-urls-fn (partial driver/default-field-percent-urls driver)}}] + field-percent-urls-fn (partial driver/default-field-percent-urls driver) + calculate-row-count? true}}] (fn [driver table new-field-ids] (let [driver (assoc driver :field-avg-length field-avg-length-fn, :field-percent-urls field-percent-urls-fn)] - {:row_count (u/try-apply table-row-count table) + {:row_count (when calculate-row-count? (u/try-apply table-row-count table)) :fields (for [{:keys [id] :as field} (table/fields table)] (let [new-field? (contains? new-field-ids id)] (cond->> {:id id} diff --git a/src/metabase/util.clj b/src/metabase/util.clj index 993e311ee7345c395d357e0497ef820db8318ca2..d73dc84cf240d6b47312984f5ef8b545f9d7baaf 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -822,3 +822,12 @@ "Increment N if it is non-`nil`, otherwise return `1` (e.g. as if incrementing `0`)." [n] (if n (inc n) 1)) + +(defn occurances-of-substring + "Return the number of times SUBSTR occurs in string S." + ^Integer [^String s, ^String substr] + (when (and (seq s) (seq substr)) + (loop [index 0, cnt 0] + (if-let [new-index (s/index-of s substr index)] + (recur (inc new-index) (inc cnt)) + cnt)))) diff --git a/test/metabase/api/metric_test.clj b/test/metabase/api/metric_test.clj index 42b5676409694c06bcd216a0f9364094e7f9158e..925031a4d499c1e05ae67debece2cf3a656adf08 100644 --- a/test/metabase/api/metric_test.clj +++ b/test/metabase/api/metric_test.clj @@ -9,7 +9,7 @@ [metric :refer [Metric], :as metric] [revision :refer [Revision]] [table :refer [Table]]) - [metabase.test.data :refer :all] + [metabase.test.data :refer :all, :as data] [metabase.test.data.users :refer :all] [metabase.test.util :as tu])) @@ -372,6 +372,7 @@ (tt/expect-with-temp [Metric [metric-1 {:name "Metric A"}] Metric [metric-2 {:name "Metric B"}] Metric [_ {:is_active false}]] ; inactive metrics shouldn't show up - (tu/mappify (hydrate [metric-1 - metric-2] :creator)) + (tu/mappify (hydrate [(assoc metric-1 :database_id (data/id)) + (assoc metric-2 :database_id (data/id))] + :creator)) ((user->client :rasta) :get 200 "metric/")) diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 01c62c0818b8878f93ca5c24423b4de4ed4246c5..08083bbb6651d03eed26fdd9a6d94da0ba7daec3 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -27,14 +27,15 @@ (defn- pulse-details [pulse] (tu/match-$ pulse - {:id $ - :name $ - :created_at $ - :updated_at $ - :creator_id $ - :creator (user-details (db/select-one 'User :id (:creator_id pulse))) - :cards (map pulse-card-details (:cards pulse)) - :channels (map pulse-channel-details (:channels pulse))})) + {:id $ + :name $ + :created_at $ + :updated_at $ + :creator_id $ + :creator (user-details (db/select-one 'User :id (:creator_id pulse))) + :cards (map pulse-card-details (:cards pulse)) + :channels (map pulse-channel-details (:channels pulse)) + :skip_if_empty $})) (defn- pulse-response [{:keys [created_at updated_at], :as pulse}] (-> pulse @@ -87,27 +88,29 @@ (tt/expect-with-temp [Card [card1] Card [card2]] - {:name "A Pulse" - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :created_at true - :updated_at true - :cards (mapv pulse-card-details [card1 card2]) - :channels [{:enabled true - :channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :schedule_day nil - :schedule_frame nil - :recipients []}]} - (-> (pulse-response ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" - :cards [{:id (:id card1)} {:id (:id card2)}] - :channels [{:enabled true - :channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :schedule_day nil - :recipients []}]})) + {:name "A Pulse" + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :created_at true + :updated_at true + :cards (mapv pulse-card-details [card1 card2]) + :channels [{:enabled true + :channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :schedule_day nil + :schedule_frame nil + :recipients []}] + :skip_if_empty false} + (-> (pulse-response ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" + :cards [{:id (:id card1)} {:id (:id card2)}] + :channels [{:enabled true + :channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :schedule_day nil + :recipients []}] + :skip_if_empty false})) (update :channels remove-extra-channels-fields))) @@ -143,29 +146,31 @@ (tt/expect-with-temp [Pulse [pulse] Card [card]] - {:name "Updated Pulse" - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :created_at true - :updated_at true - :cards [(pulse-card-details card)] - :channels [{:enabled true - :channel_type "slack" - :schedule_type "hourly" - :schedule_hour nil - :schedule_day nil - :schedule_frame nil - :details {:channels "#general"} - :recipients []}]} - (-> (pulse-response ((user->client :rasta) :put 200 (format "pulse/%d" (:id pulse)) {:name "Updated Pulse" - :cards [{:id (:id card)}] - :channels [{:enabled true - :channel_type "slack" - :schedule_type "hourly" - :schedule_hour 12 - :schedule_day "mon" - :recipients [] - :details {:channels "#general"}}]})) + {:name "Updated Pulse" + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :created_at true + :updated_at true + :cards [(pulse-card-details card)] + :channels [{:enabled true + :channel_type "slack" + :schedule_type "hourly" + :schedule_hour nil + :schedule_day nil + :schedule_frame nil + :details {:channels "#general"} + :recipients []}] + :skip_if_empty false} + (-> (pulse-response ((user->client :rasta) :put 200 (format "pulse/%d" (:id pulse)) {:name "Updated Pulse" + :cards [{:id (:id card)}] + :channels [{:enabled true + :channel_type "slack" + :schedule_type "hourly" + :schedule_hour 12 + :schedule_day "mon" + :recipients [] + :details {:channels "#general"}}] + :skip_if_empty false})) (update :channels remove-extra-channels-fields))) diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index c779721e53d09752c319ab28f5ded28be73ad393..165cf449c6da52a997da44388850e7ca9ec6ec22 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -205,7 +205,7 @@ (driver/describe-database pg-driver database))))) ;; make sure that if a view is dropped and recreated that the original Table object is marked active rather than a new one being created (#3331) -(expect +(expect-with-engine :postgres [{:name "angry_birds", :active true}] (let [details (i/database->connection-details pg-driver :db {:database-name "dropped_views_test"}) spec (sql/connection-details->spec pg-driver details) diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj index 996f2a64ab5d0d900fd1b078b39ea0464de4b7a2..1273f76fdf9ee15e90b83c403a3bbb60bb45b371 100644 --- a/test/metabase/models/pulse_test.clj +++ b/test/metabase/models/pulse_test.clj @@ -20,8 +20,8 @@ ;; create a channel then select its details (defn- create-pulse-then-select! - [name creator cards channels] - (let [{:keys [cards channels] :as pulse} (create-pulse! name creator cards channels)] + [name creator cards channels skip-if-empty?] + (let [{:keys [cards channels] :as pulse} (create-pulse! name creator cards channels skip-if-empty?)] (-> pulse (dissoc :id :creator :created_at :updated_at) (assoc :cards (mapv #(dissoc % :id) cards)) @@ -43,21 +43,22 @@ ;; retrieve-pulse ;; this should cover all the basic Pulse attributes (expect - {:creator_id (user->id :rasta) - :creator (user-details :rasta) - :name "Lodi Dodi" - :cards [{:name "Test Card" - :description nil - :display :table}] - :channels [{:enabled true - :schedule_type :daily - :schedule_hour 15 - :schedule_frame nil - :channel_type :email - :details {:other "stuff"} - :schedule_day nil - :recipients [{:email "foo@bar.com"} - (dissoc (user-details :rasta) :is_superuser :is_qbnewb)]}]} + {:creator_id (user->id :rasta) + :creator (user-details :rasta) + :name "Lodi Dodi" + :cards [{:name "Test Card" + :description nil + :display :table}] + :channels [{:enabled true + :schedule_type :daily + :schedule_hour 15 + :schedule_frame nil + :channel_type :email + :details {:other "stuff"} + :schedule_day nil + :recipients [{:email "foo@bar.com"} + (dissoc (user-details :rasta) :is_superuser :is_qbnewb)]}] + :skip_if_empty false} (tt/with-temp* [Pulse [{pulse-id :id} {:name "Lodi Dodi"}] PulseChannel [{channel-id :id :as channel} {:pulse_id pulse-id :details {:other "stuff" @@ -119,23 +120,28 @@ ;; create-pulse! ;; simple example with a single card (expect - {:creator_id (user->id :rasta) - :name "Booyah!" - :channels [{:enabled true - :schedule_type :daily - :schedule_hour 18 - :schedule_frame nil - :channel_type :email - :recipients [{:email "foo@bar.com"}] - :schedule_day nil}] - :cards [{:name "Test Card" - :description nil - :display :table}]} + {:creator_id (user->id :rasta) + :name "Booyah!" + :channels [{:enabled true + :schedule_type :daily + :schedule_hour 18 + :schedule_frame nil + :channel_type :email + :recipients [{:email "foo@bar.com"}] + :schedule_day nil}] + :cards [{:name "Test Card" + :description nil + :display :table}] + :skip_if_empty false} (tt/with-temp Card [{:keys [id]} {:name "Test Card"}] - (create-pulse-then-select! "Booyah!" (user->id :rasta) [id] [{:channel_type :email - :schedule_type :daily - :schedule_hour 18 - :recipients [{:email "foo@bar.com"}]}]))) + (create-pulse-then-select! "Booyah!" + (user->id :rasta) + [id] + [{:channel_type :email + :schedule_type :daily + :schedule_hour 18 + :recipients [{:email "foo@bar.com"}]}] + false))) ;; update-pulse! ;; basic update. we are testing several things here @@ -146,31 +152,33 @@ ;; 5. ability to create new channels ;; 6. ability to update cards and ensure proper ordering (expect - {:creator_id (user->id :rasta) - :name "We like to party" - :cards [{:name "Bar Card" - :description nil - :display :bar} - {:name "Test Card" - :description nil - :display :table}] - :channels [{:enabled true - :schedule_type :daily - :schedule_hour 18 - :schedule_frame nil - :channel_type :email - :schedule_day nil - :recipients [{:email "foo@bar.com"} - (dissoc (user-details :crowberto) :is_superuser :is_qbnewb)]}]} + {:creator_id (user->id :rasta) + :name "We like to party" + :cards [{:name "Bar Card" + :description nil + :display :bar} + {:name "Test Card" + :description nil + :display :table}] + :channels [{:enabled true + :schedule_type :daily + :schedule_hour 18 + :schedule_frame nil + :channel_type :email + :schedule_day nil + :recipients [{:email "foo@bar.com"} + (dissoc (user-details :crowberto) :is_superuser :is_qbnewb)]}] + :skip_if_empty false} (tt/with-temp* [Pulse [{pulse-id :id}] Card [{card-id-1 :id} {:name "Test Card"}] Card [{card-id-2 :id} {:name "Bar Card", :display :bar}]] - (update-pulse-then-select! {:id pulse-id - :name "We like to party" - :creator_id (user->id :crowberto) - :cards [card-id-2 card-id-1] - :channels [{:channel_type :email - :schedule_type :daily - :schedule_hour 18 - :recipients [{:email "foo@bar.com"} - {:id (user->id :crowberto)}]}]}))) + (update-pulse-then-select! {:id pulse-id + :name "We like to party" + :creator_id (user->id :crowberto) + :cards [card-id-2 card-id-1] + :channels [{:channel_type :email + :schedule_type :daily + :schedule_hour 18 + :recipients [{:email "foo@bar.com"} + {:id (user->id :crowberto)}]}] + :skip-if-empty? false}))) diff --git a/test/metabase/models/setting_test.clj b/test/metabase/models/setting_test.clj index c4d6fb2ba8939c7bef523b19493a9ee24bb896fa..89210b0fb8f358348952d00cd84b912fa3a1b28a 100644 --- a/test/metabase/models/setting_test.clj +++ b/test/metabase/models/setting_test.clj @@ -238,3 +238,23 @@ {:a 100, :b 200} (do (test-json-setting {:a 100, :b 200}) (test-json-setting))) + + +;;; make sure that if for some reason the cache gets out of sync it will reset so we can still set new settings values (#4178) + +(setting/defsetting ^:private toucan-name + "Name for the Metabase Toucan mascot.") + +(expect + "Banana Beak" + (do + ;; clear out any existing values of `toucan-name` + (db/simple-delete! setting/Setting {:key "toucan-name"}) + ;; restore the cache + ((resolve 'metabase.models.setting/restore-cache-if-needed!)) + ;; now set a value for the `toucan-name` setting the wrong way + (db/insert! setting/Setting {:key "toucan-name", :value "Rasta"}) + ;; ok, now try to set the Setting the correct way + (toucan-name "Banana Beak") + ;; ok, make sure the setting was set + (toucan-name))) diff --git a/test/metabase/publc_settings_test.clj b/test/metabase/publc_settings_test.clj index dd97384d940c35d566b0d19c13f3efa1ff9b80c8..22249289c1791d4f04416d1b30683b8aa2314cc3 100644 --- a/test/metabase/publc_settings_test.clj +++ b/test/metabase/publc_settings_test.clj @@ -3,9 +3,35 @@ [metabase.public-settings :as public-settings] [metabase.test.util :as tu])) -;; double-check that setting the `site-url` setting will automatically strip off trailing slashes + ;; double-check that setting the `site-url` setting will automatically strip off trailing slashes (expect "http://localhost:3000" (tu/with-temporary-setting-values [site-url nil] (public-settings/site-url "http://localhost:3000/") (public-settings/site-url))) + + ;; double-check that setting the `site-url` setting will prepend `http://` if no protocol was specified +(expect + "http://localhost:3000" + (tu/with-temporary-setting-values [site-url nil] + (public-settings/site-url "localhost:3000") + (public-settings/site-url))) + +(expect + "http://localhost:3000" + (tu/with-temporary-setting-values [site-url nil] + (public-settings/site-url "localhost:3000") + (public-settings/site-url))) + +(expect + "http://localhost:3000" + (tu/with-temporary-setting-values [site-url nil] + (public-settings/site-url "http://localhost:3000") + (public-settings/site-url))) + +;; if https:// was specified it should keep it +(expect + "https://localhost:3000" + (tu/with-temporary-setting-values [site-url nil] + (public-settings/site-url "https://localhost:3000") + (public-settings/site-url))) diff --git a/test/metabase/query_processor/parameters_test.clj b/test/metabase/query_processor/parameters_test.clj index f5262498246a10d42c023f4ea490c976b60d5de0..c4d1ca106c86e7dac4d462e798bcc24ad6f06b49 100644 --- a/test/metabase/query_processor/parameters_test.clj +++ b/test/metabase/query_processor/parameters_test.clj @@ -17,30 +17,45 @@ [users :refer :all]) [metabase.test.util :as tu])) -(tu/resolve-private-vars metabase.query-processor.parameters - absolute-date->range relative-date->range) - -(expect {:end "2016-03-31", :start "2016-01-01"} (absolute-date->range "Q1-2016")) -(expect {:end "2016-02-29", :start "2016-02-01"} (absolute-date->range "2016-02")) -(expect {:end "2016-04-18", :start "2016-04-18"} (absolute-date->range "2016-04-18")) -(expect {:end "2016-04-23", :start "2016-04-18"} (absolute-date->range "2016-04-18~2016-04-23")) - ;; we hard code "now" to a specific point in time so that we can control the test output -(defn- test-relative [value] +(defn- test-date->range [value] (with-redefs-fn {#'clj-time.core/now (fn [] (t/date-time 2016 06 07 12 0 0))} - #(relative-date->range value nil))) - -(expect {:end "2016-06-06", :start "2016-05-31"} (test-relative "past7days")) -(expect {:end "2016-06-06", :start "2016-05-08"} (test-relative "past30days")) -(expect {:end "2016-06-11", :start "2016-06-05"} (test-relative "thisweek")) -(expect {:end "2016-06-30", :start "2016-06-01"} (test-relative "thismonth")) -(expect {:end "2016-12-31", :start "2016-01-01"} (test-relative "thisyear")) -(expect {:end "2016-06-04", :start "2016-05-29"} (test-relative "lastweek")) -(expect {:end "2016-05-31", :start "2016-05-01"} (test-relative "lastmonth")) -(expect {:end "2015-12-31", :start "2015-01-01"} (test-relative "lastyear")) -(expect {:end "2016-06-06", :start "2016-06-06"} (test-relative "yesterday")) -(expect {:end "2016-06-07", :start "2016-06-07"} (test-relative "today")) - + #(date-string->range value nil))) + +(expect {:end "2016-03-31", :start "2016-01-01"} (test-date->range "Q1-2016")) +(expect {:end "2016-02-29", :start "2016-02-01"} (test-date->range "2016-02")) +(expect {:end "2016-04-18", :start "2016-04-18"} (test-date->range "2016-04-18")) +(expect {:end "2016-04-23", :start "2016-04-18"} (test-date->range "2016-04-18~2016-04-23")) +(expect {:end "2016-04-23", :start "2016-04-18"} (test-date->range "2016-04-18~2016-04-23")) +(expect {:start "2016-04-18"} (test-date->range "2016-04-18~")) +(expect {:end "2016-04-18"} (test-date->range "~2016-04-18")) + +(expect {:end "2016-06-06", :start "2016-06-04"} (test-date->range "past3days")) +(expect {:end "2016-06-06", :start "2016-05-31"} (test-date->range "past7days")) +(expect {:end "2016-06-06", :start "2016-05-08"} (test-date->range "past30days")) +(expect {:end "2016-05-31", :start "2016-04-01"} (test-date->range "past2months")) +(expect {:end "2016-05-31", :start "2015-05-01"} (test-date->range "past13months")) +(expect {:end "2015-12-31", :start "2015-01-01"} (test-date->range "past1years")) +(expect {:end "2015-12-31", :start "2000-01-01"} (test-date->range "past16years")) + +(expect {:end "2016-06-10", :start "2016-06-08"} (test-date->range "next3days")) +(expect {:end "2016-06-14", :start "2016-06-08"} (test-date->range "next7days")) +(expect {:end "2016-07-07", :start "2016-06-08"} (test-date->range "next30days")) +(expect {:end "2016-08-31", :start "2016-07-01"} (test-date->range "next2months")) +(expect {:end "2017-07-31", :start "2016-07-01"} (test-date->range "next13months")) +(expect {:end "2017-12-31", :start "2017-01-01"} (test-date->range "next1years")) +(expect {:end "2032-12-31", :start "2017-01-01"} (test-date->range "next16years")) + +(expect {:end "2016-06-07", :start "2016-06-07"} (test-date->range "thisday")) +(expect {:end "2016-06-11", :start "2016-06-05"} (test-date->range "thisweek")) +(expect {:end "2016-06-30", :start "2016-06-01"} (test-date->range "thismonth")) +(expect {:end "2016-12-31", :start "2016-01-01"} (test-date->range "thisyear")) + +(expect {:end "2016-06-04", :start "2016-05-29"} (test-date->range "lastweek")) +(expect {:end "2016-05-31", :start "2016-05-01"} (test-date->range "lastmonth")) +(expect {:end "2015-12-31", :start "2015-01-01"} (test-date->range "lastyear")) +(expect {:end "2016-06-06", :start "2016-06-06"} (test-date->range "yesterday")) +(expect {:end "2016-06-07", :start "2016-06-07"} (test-date->range "today")) ;;; +-------------------------------------------------------------------------------------------------------+ ;;; | MBQL QUERIES | diff --git a/test/metabase/query_processor/sql_parameters_test.clj b/test/metabase/query_processor/sql_parameters_test.clj index 679f1bc3b328d35cdce8c4c423a62d0e64c89249..1fbf45c83e4e6c00ad3494fe62c0542ea2802f8d 100644 --- a/test/metabase/query_processor/sql_parameters_test.clj +++ b/test/metabase/query_processor/sql_parameters_test.clj @@ -315,7 +315,6 @@ #inst "2016-08-01T00:00:00.000000000-00:00"]} (expand-with-dimension-param {:type "date/range", :value "2016-07-01~2016-08-01"})) - ;; dimension (date/month-year) (expect {:query "SELECT * FROM checkins WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) BETWEEN ? AND ?;" @@ -330,6 +329,18 @@ #inst "2016-03-31T00:00:00.000000000-00:00"]} (expand-with-dimension-param {:type "date/quarter-year", :value "Q1-2016"})) +;; dimension (date/all-options, before) +(expect + {:query "SELECT * FROM checkins WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) < ?;" + :params [#inst "2016-07-01T00:00:00.000000000-00:00"]} + (expand-with-dimension-param {:type "date/all-options", :value "~2016-07-01"})) + +;; dimension (date/all-options, after) +(expect + {:query "SELECT * FROM checkins WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) > ?;" + :params [#inst "2016-07-01T00:00:00.000000000-00:00"]} + (expand-with-dimension-param {:type "date/all-options", :value "2016-07-01~"})) + ;; relative date -- "yesterday" (expect {:query "SELECT * FROM checkins WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ?;" @@ -516,3 +527,22 @@ :native {:query "SELECT count(*) FROM PRODUCTS WHERE TITLE LIKE {{x}}", :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}}}, :parameters [{:type "category", :target ["variable" ["template-tag" "x"]]}]}))) + +;; make sure that you can use the same parameter multiple times (#4659) +(expect + {:query "SELECT count(*) FROM products WHERE title LIKE ? AND subtitle LIKE ?" + :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}} + :params ["%Toucan%" "%Toucan%"]} + (:native (expand-params {:driver (driver/engine->driver :h2) + :native {:query "SELECT count(*) FROM products WHERE title LIKE {{x}} AND subtitle LIKE {{x}}", + :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}}}, + :parameters [{:type "category", :target ["variable" ["template-tag" "x"]]}]}))) + +(expect + {:query "SELECT * FROM ORDERS WHERE true AND ID = ? OR USER_ID = ?" + :template_tags {:id {:name "id", :display_name "ID", :type "text"}} + :params ["2" "2"]} + (:native (expand-params {:driver (driver/engine->driver :h2) + :native {:query "SELECT * FROM ORDERS WHERE true [[ AND ID = {{id}} OR USER_ID = {{id}} ]]" + :template_tags {:id {:name "id", :display_name "ID", :type "text"}}} + :parameters [{:type "category", :target ["variable" ["template-tag" "id"]], :value "2"}]}))) diff --git a/test/metabase/sync_database_test.clj b/test/metabase/sync_database_test.clj index 4b1a7e2e0c3fd69028b472c1aec8108068e24aec..e3f963012af804337e025d6502f7d67f0ff1ecf7 100644 --- a/test/metabase/sync_database_test.clj +++ b/test/metabase/sync_database_test.clj @@ -1,17 +1,23 @@ (ns metabase.sync-database-test - (:require [expectations :refer :all] + (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as str] + [expectations :refer :all] (toucan [db :as db] [hydrate :refer [hydrate]]) [toucan.util.test :as tt] - [metabase.driver :as driver] + (metabase [db :as mdb] + [driver :as driver]) + [metabase.driver.generic-sql :as sql] (metabase.models [database :refer [Database]] [field :refer [Field]] [field-values :refer [FieldValues]] [raw-table :refer [RawTable]] [table :refer [Table]]) [metabase.sync-database :refer :all] - (metabase.test [data :refer :all] - [util :refer [resolve-private-vars] :as tu]) + metabase.sync-database.analyze + [metabase.test.data :refer :all] + [metabase.test.data.interface :as i] + [metabase.test.util :refer [resolve-private-vars] :as tu] [metabase.util :as u])) (def ^:private ^:const sync-test-tables @@ -317,3 +323,31 @@ ;; 3. Now re-sync the table and make sure the value is back (do (sync-table! @venues-table) (get-field-values))])) + + +;;; -------------------- Make sure that if a Field's cardinality passes `metabase.sync-database.analyze/low-cardinality-threshold` (currently 300) (#3215) -------------------- +(expect + false + (let [details {:db (str "mem:" (tu/random-name) ";DB_CLOSE_DELAY=10")}] + (binding [mdb/*allow-potentailly-unsafe-connections* true] + (tt/with-temp Database [db {:engine :h2, :details details}] + (let [driver (driver/engine->driver :h2) + spec (sql/connection-details->spec driver details) + exec! #(doseq [statement %] + (println (jdbc/execute! spec [statement]))) + insert-range #(str "INSERT INTO blueberries_consumed (num) VALUES " + (str/join ", " (for [n %] + (str "(" n ")"))))] + ;; create the `blueberries_consumed` table and insert a 100 values + (exec! ["CREATE TABLE blueberries_consumed (num INTEGER NOT NULL);" + (insert-range (range 100))]) + (sync-database! db, :full-sync? true) + (let [table-id (db/select-one-id Table :db_id (u/get-id db)) + field-id (db/select-one-id Field :table_id table-id)] + ;; field values should exist... + (assert (= (count (db/select-one-field :values FieldValues :field_id field-id)) + 100)) + ;; ok, now insert enough rows to push the field past the `low-cardinality-threshold` and sync again, there should be no more field values + (exec! [(insert-range (range 100 (+ 100 @(resolve 'metabase.sync-database.analyze/low-cardinality-threshold))))]) + (sync-database! db, :full-sync? true) + (db/exists? FieldValues :field_id field-id))))))) diff --git a/test/metabase/test/data/interface.clj b/test/metabase/test/data/interface.clj index af3208d855d94c77711e16efbce683abcf0709fc..f460946e7dac64caedca25ce1ad18eef7cc67eb0 100644 --- a/test/metabase/test/data/interface.clj +++ b/test/metabase/test/data/interface.clj @@ -30,8 +30,9 @@ (defn escaped-name "Return escaped version of database name suitable for use as a filename / database name / etc." - ^String [^DatabaseDefinition database-definition] - (str/replace (:database-name database-definition) #"\s+" "_")) + ^String [^DatabaseDefinition {:keys [database-name]}] + {:pre [(string? database-name)]} + (str/replace database-name #"\s+" "_")) (defn db-qualified-table-name "Return a combined table name qualified with the name of its database, suitable for use as an identifier. diff --git a/test/metabase/test/data/users.clj b/test/metabase/test/data/users.clj index 2c13813a3a1e057fd71c0af4078d9285f363dcd6..d08a28c4017d6e02b3bd783927be181eb1144f30 100644 --- a/test/metabase/test/data/users.clj +++ b/test/metabase/test/data/users.clj @@ -3,8 +3,8 @@ ;; TODO - maybe this namespace should just be `metabase.test.users`. (:require [medley.core :as m] [toucan.db :as db] - (metabase [config :as config] - [core :as core]) + [metabase.config :as config] + [metabase.core.initialization-status :as init-status] [metabase.http-client :as http] (metabase.models [permissions-group :as perms-group] [user :refer [User]]) @@ -56,7 +56,7 @@ ;; only need to wait when running unit tests. When doing REPL dev and using the test users we're probably ;; the server is probably a separate process (`lein ring server`) (when config/is-test? - (when-not (core/initialized?) + (when-not (init-status/complete?) (when (<= max-wait-seconds 0) (throw (Exception. "Metabase still hasn't finished initializing."))) (println (format "Metabase is not yet initialized, waiting 1 second (max wait remaining: %d seconds)..." max-wait-seconds)) diff --git a/test/metabase/test_setup.clj b/test/metabase/test_setup.clj index 7d81cc710acee50e5324d72c96932d85e36f3d62..3f9b447793cb1f942341abdf1fe485a909ac64de 100644 --- a/test/metabase/test_setup.clj +++ b/test/metabase/test_setup.clj @@ -5,8 +5,9 @@ [clojure.set :as set] [clojure.tools.logging :as log] [expectations :refer :all] - (metabase [core :as core] - [db :as mdb] + [metabase.core :as core] + [metabase.core.initialization-status :as init-status] + (metabase [db :as mdb] [driver :as driver]) (metabase.models [setting :as setting] [table :refer [Table]]) @@ -79,7 +80,7 @@ (log/info (format "Setting up %s test DB and running migrations..." (name (mdb/db-type)))) (mdb/setup-db! :auto-migrate true) (setting/set! :site-name "Metabase Test") - (core/initialization-complete!) + (init-status/set-complete!) ;; make sure the driver test extensions are loaded before running the tests. :reload them because otherwise we get wacky 'method in protocol not implemented' errors ;; when running tests against an individual namespace diff --git a/test/metabase/util_test.clj b/test/metabase/util_test.clj index a17d043795dad96442589aaabc2451c7c9f6fe03..8520c9866e9978d35e3ba369980f980ee988d8ef 100644 --- a/test/metabase/util_test.clj +++ b/test/metabase/util_test.clj @@ -204,9 +204,29 @@ (select-nested-keys {} [:c])) -;; tests for base-64-string? +;;; tests for base-64-string? (expect (base-64-string? "ABc")) (expect (base-64-string? "ABc/+asdasd==")) (expect false (base-64-string? 100)) (expect false (base-64-string? "<<>>")) (expect false (base-64-string? "{\"a\": 10}")) + + +;;; tests for `occurances-of-substring` + +;; return nil if one or both strings are nil or empty +(expect nil (occurances-of-substring nil nil)) +(expect nil (occurances-of-substring nil "")) +(expect nil (occurances-of-substring "" nil)) +(expect nil (occurances-of-substring "" "")) +(expect nil (occurances-of-substring "ABC" "")) +(expect nil (occurances-of-substring "" " ABC")) + +(expect 1 (occurances-of-substring "ABC" "A")) +(expect 2 (occurances-of-substring "ABA" "A")) +(expect 3 (occurances-of-substring "AAA" "A")) + +(expect 0 (occurances-of-substring "ABC" "{{id}}")) +(expect 1 (occurances-of-substring "WHERE ID = {{id}}" "{{id}}")) +(expect 2 (occurances-of-substring "WHERE ID = {{id}} OR USER_ID = {{id}}" "{{id}}")) +(expect 3 (occurances-of-substring "WHERE ID = {{id}} OR USER_ID = {{id}} OR TOUCAN_ID = {{id}} OR BIRD_ID = {{bird}}" "{{id}}")) diff --git a/yarn.lock b/yarn.lock index 862aac640974ad0e985bf73a603ec46f027e7d48..65023c03997031bf46ff207d48f499d916c07e1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -218,7 +218,7 @@ annotate-react-dom@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/annotate-react-dom/-/annotate-react-dom-1.1.0.tgz#607c14d2565198d4bf365f6f05c60a61ba939a16" -ansi-escapes@^1.1.0, ansi-escapes@^1.4.0: +ansi-escapes@^1.0.0, ansi-escapes@^1.1.0, ansi-escapes@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" @@ -248,6 +248,12 @@ ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" +ansi-styles@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.0.0.tgz#5404e93a544c4fec7f048262977bebfe3155e0c1" + dependencies: + color-convert "^1.0.0" + ansicolors@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.2.1.tgz#be089599097b74a5c9c4a84a0cdbcdb62bd87aef" @@ -267,6 +273,10 @@ anymatch@^1.3.0: arrify "^1.0.0" micromatch "^2.1.5" +app-root-path@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.0.1.tgz#cd62dcf8e4fd5a417efc664d2e5b10653c651b46" + append-transform@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991" @@ -390,6 +400,14 @@ assert@^1.1.1: dependencies: util "0.10.3" +ast-types@0.8.18: + version "0.8.18" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.8.18.tgz#c8b98574898e8914e9d8de74b947564a9fe929af" + +ast-types@0.9.4: + version "0.9.4" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.4.tgz#410d1f81890aeb8e0a38621558ba5869ae53c91b" + ast-types@0.9.5: version "0.9.5" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.5.tgz#1a660a09945dbceb1f9c9cbb715002617424e04a" @@ -464,7 +482,7 @@ babel-cli@^6.11.4: optionalDependencies: chokidar "^1.6.1" -babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0: +babel-code-frame@6.22.0, babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4" dependencies: @@ -1370,7 +1388,7 @@ babel-types@^6.15.0, babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.19 lodash "^4.2.0" to-fast-properties "^1.0.1" -babylon@^6.11.0, babylon@^6.13.0, babylon@^6.15.0: +babylon@6.15.0, babylon@^6.11.0, babylon@^6.13.0, babylon@^6.15.0: version "6.15.0" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e" @@ -1708,7 +1726,7 @@ chalk@0.5.1: strip-ansi "^0.3.0" supports-color "^0.2.0" -chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: +chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" dependencies: @@ -1801,18 +1819,29 @@ cli-color@^0.3.2: memoizee "~0.3.8" timers-ext "0.1" -cli-cursor@^1.0.1: +cli-cursor@^1.0.1, cli-cursor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" dependencies: restore-cursor "^1.0.1" +cli-spinners@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" + cli-table@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" dependencies: colors "1.0.3" +cli-truncate@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" + dependencies: + slice-ansi "0.0.4" + string-width "^1.0.1" + cli-usage@^0.1.1: version "0.1.4" resolved "https://registry.yarnpkg.com/cli-usage/-/cli-usage-0.1.4.tgz#7c01e0dc706c234b39c933838c8e20b2175776e2" @@ -1862,7 +1891,7 @@ color-convert@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" -color-convert@^1.3.0, color-convert@^1.8.2: +color-convert@^1.0.0, color-convert@^1.3.0, color-convert@^1.8.2: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" dependencies: @@ -1919,7 +1948,7 @@ colors@1.0.3, colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@1.1.2, colors@^1.1.0, colors@~1.1.2: +colors@1.1.2, colors@>=0.6.2, colors@^1.1.0, colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -2100,6 +2129,19 @@ core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-1.1.0.tgz#0dea0f9804efdfb929fbb1b188e25553ea053d37" + dependencies: + graceful-fs "^4.1.2" + js-yaml "^3.4.3" + minimist "^1.2.0" + object-assign "^4.0.1" + os-homedir "^1.0.1" + parse-json "^2.2.0" + pinkie-promise "^2.0.0" + require-from-string "^1.1.0" + cosmiconfig@^2.1.0, cosmiconfig@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-2.1.1.tgz#817f2c2039347a1e9bf7d090c0923e53f749ca82" @@ -2141,6 +2183,14 @@ cross-spawn-async@^1.0.1: lru-cache "^2.6.5" which "^1.1.1" +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + crossfilter2@~1.3: version "1.3.14" resolved "https://registry.yarnpkg.com/crossfilter2/-/crossfilter2-1.3.14.tgz#c45bd8d335f6c91accbac26eda203377f195f680" @@ -2307,6 +2357,12 @@ custom-event@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" +cxs@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/cxs/-/cxs-3.0.4.tgz#2e1a1537742931a53dbe3157afbf121da62df797" + dependencies: + glamor "^2.17.14" + cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" @@ -2327,7 +2383,7 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -date-fns@^1.23.0: +date-fns@^1.23.0, date-fns@^1.27.2: version "1.27.2" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.27.2.tgz#ce82f420bc028356cc661fc55c0494a56a990c9c" @@ -2570,6 +2626,10 @@ electron-to-chromium@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.2.3.tgz#4b4d04d237c301f72e2d15c2137b2b79f9f5ab76" +elegant-spinner@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" + element-class@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e" @@ -2967,7 +3027,7 @@ estraverse@~4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.1.1.tgz#f6caca728933a850ef90661d0e17982ba47111a2" -esutils@^2.0.0, esutils@^2.0.2: +esutils@2.0.2, esutils@^2.0.0, esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" @@ -3012,6 +3072,18 @@ exec-sh@^0.2.0: dependencies: merge "^1.1.3" +execa@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.6.0.tgz#934fc9f04a9febb4d4b449d976e92cfd95ef4f6e" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + exenv@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.0.tgz#3835f127abf075bfe082d0aed4484057c78e3c89" @@ -3141,7 +3213,7 @@ fb-watchman@^1.8.0, fb-watchman@^1.9.0: dependencies: bser "1.0.2" -fbjs@^0.8.1, fbjs@^0.8.4: +fbjs@^0.8.1, fbjs@^0.8.4, fbjs@^0.8.8: version "0.8.9" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.9.tgz#180247fbd347dcc9004517b904f865400a0c8f14" dependencies: @@ -3153,7 +3225,7 @@ fbjs@^0.8.1, fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.9" -figures@^1.3.5: +figures@^1.3.5, figures@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" dependencies: @@ -3224,6 +3296,10 @@ find-cache-dir@^0.1.1: mkdirp "^0.5.1" pkg-dir "^1.0.0" +find-parent-dir@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" + find-root@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/find-root/-/find-root-0.1.2.tgz#98d2267cff1916ccaf2743b3a0eea81d79d7dcd1" @@ -3252,6 +3328,14 @@ flow-bin@^0.37.4: version "0.37.4" resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.37.4.tgz#3d8da2ef746e80e730d166e09040f4198969b76b" +flow-parser@0.40.0: + version "0.40.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.40.0.tgz#b3444742189093323c4319c4fe9d35391f46bcbc" + dependencies: + ast-types "0.8.18" + colors ">=0.6.2" + minimist ">=0.2.0" + flux-standard-action@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/flux-standard-action/-/flux-standard-action-0.6.1.tgz#6f34211b94834ea1c3cc30f4e7afad3d0fbf71a2" @@ -3411,12 +3495,28 @@ get-caller-file@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" +get-stdin@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + getpass@^0.1.1: version "0.1.6" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6" dependencies: assert-plus "^1.0.0" +glamor@^2.17.14: + version "2.20.24" + resolved "https://registry.yarnpkg.com/glamor/-/glamor-2.20.24.tgz#a299af2eec687322634ba38e4a0854d8743d2041" + dependencies: + babel-runtime "^6.18.0" + fbjs "^0.8.8" + object-assign "^4.1.0" + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -3430,7 +3530,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1: +glob@7.1.1, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" dependencies: @@ -3702,6 +3802,15 @@ humanize-plus@^1.8.1: version "1.8.2" resolved "https://registry.yarnpkg.com/humanize-plus/-/humanize-plus-1.8.2.tgz#a65b34459ad6367adbb3707a82a3c9f916167030" +husky@^0.13.2: + version "0.13.2" + resolved "https://registry.yarnpkg.com/husky/-/husky-0.13.2.tgz#9dcf212f88e61dba36f17be1a202ed61ff6c0661" + dependencies: + chalk "^1.1.3" + find-parent-dir "^0.3.0" + is-ci "^1.0.9" + normalize-path "^1.0.0" + icepick@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/icepick/-/icepick-1.3.0.tgz#e4942842ed8f9ee778d7dd78f7e36627f49fdaef" @@ -3762,6 +3871,16 @@ imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" +indent-string@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" + dependencies: + repeating "^2.0.0" + +indent-string@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.1.0.tgz#08ff4334603388399b329e6b9538dc7a3cf5de7d" + indexes-of@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" @@ -4021,7 +4140,7 @@ is-retina@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-retina/-/is-retina-1.0.3.tgz#d7401b286bea2ae37f62477588de504d0b8647e3" -is-stream@^1.0.1: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -4297,6 +4416,13 @@ jest-matcher-utils@^18.1.0: chalk "^1.1.3" pretty-format "^18.1.0" +jest-matcher-utils@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-19.0.0.tgz#5ecd9b63565d2b001f61fbf7ec4c7f537964564d" + dependencies: + chalk "^1.1.3" + pretty-format "^19.0.0" + jest-matchers@^18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/jest-matchers/-/jest-matchers-18.1.0.tgz#0341484bf87a1fd0bac0a4d2c899e2b77a3f1ead" @@ -4368,6 +4494,15 @@ jest-util@^18.1.0: jest-mock "^18.0.0" mkdirp "^0.5.1" +jest-validate@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-19.0.0.tgz#8c6318a20ecfeaba0ba5378bfbb8277abded4173" + dependencies: + chalk "^1.1.1" + jest-matcher-utils "^19.0.0" + leven "^2.0.0" + pretty-format "^19.0.0" + jest@^18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/jest/-/jest-18.1.0.tgz#bcebf1e203dee5c2ad2091c805300a343d9e6c7d" @@ -4639,6 +4774,10 @@ leaflet@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.0.3.tgz#1f401b98b45c8192134c6c8d69686253805007c8" +leven@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" @@ -4646,6 +4785,65 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lint-staged@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-3.3.1.tgz#b725d98a2be1f82cb228069fab682f503c95234d" + dependencies: + app-root-path "^2.0.0" + cosmiconfig "^1.1.0" + execa "^0.6.0" + listr "^0.11.0" + minimatch "^3.0.0" + npm-which "^3.0.1" + staged-git-files "0.0.4" + which "^1.2.11" + +listr-silent-renderer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" + +listr-update-renderer@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz#ca80e1779b4e70266807e8eed1ad6abe398550f9" + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + elegant-spinner "^1.0.1" + figures "^1.7.0" + indent-string "^3.0.0" + log-symbols "^1.0.2" + log-update "^1.0.2" + strip-ansi "^3.0.1" + +listr-verbose-renderer@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.0.tgz#44dc01bb0c34a03c572154d4d08cde9b1dc5620f" + dependencies: + chalk "^1.1.3" + cli-cursor "^1.0.2" + date-fns "^1.27.2" + figures "^1.7.0" + +listr@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.11.0.tgz#5e778bc23806ac3ab984ed75564458151f39b03e" + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + figures "^1.7.0" + indent-string "^2.1.0" + is-promise "^2.1.0" + is-stream "^1.1.0" + listr-silent-renderer "^1.1.1" + listr-update-renderer "^0.2.0" + listr-verbose-renderer "^0.4.0" + log-symbols "^1.0.2" + log-update "^1.0.2" + ora "^0.2.3" + rxjs "^5.0.0-beta.11" + stream-to-observable "^0.1.0" + strip-ansi "^3.0.1" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -4883,6 +5081,19 @@ lodash@^3.7.0, lodash@^3.8.0: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" +log-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + dependencies: + chalk "^1.0.0" + +log-update@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" + dependencies: + ansi-escapes "^1.0.0" + cli-cursor "^1.0.2" + log4js@^0.6.31: version "0.6.38" resolved "https://registry.yarnpkg.com/log4js/-/log4js-0.6.38.tgz#2c494116695d6fb25480943d3fc872e662a522fd" @@ -4912,6 +5123,13 @@ lru-cache@^2.6.5: version "2.7.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" +lru-cache@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" + dependencies: + pseudomap "^1.0.1" + yallist "^2.0.0" + lru-queue@0.1: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -5057,7 +5275,7 @@ minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" -minimist@^1.1.1, minimist@^1.2.0: +minimist@1.2.0, minimist@>=0.2.0, minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" @@ -5279,6 +5497,10 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" + normalize-path@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.0.1.tgz#47886ac1662760d4261b7d979d241709d3ce3f7a" @@ -5300,6 +5522,26 @@ normalizr@^3.0.2: version "3.2.1" resolved "https://registry.yarnpkg.com/normalizr/-/normalizr-3.2.1.tgz#85a2d3d0ffb9c3b08f4131cb8d8fbfb7e9211b35" +npm-path@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.3.tgz#15cff4e1c89a38da77f56f6055b24f975dfb2bbe" + dependencies: + which "^1.2.10" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + dependencies: + path-key "^2.0.0" + +npm-which@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-3.0.1.tgz#9225f26ec3a285c209cae67c3b11a6b4ab7140aa" + dependencies: + commander "^2.9.0" + npm-path "^2.0.2" + which "^1.2.10" + npmlog@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f" @@ -5465,6 +5707,15 @@ options@>=0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" +ora@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" + dependencies: + chalk "^1.1.1" + cli-cursor "^1.0.2" + cli-spinners "^0.1.2" + object-assign "^4.0.1" + original@>=0.0.5: version "1.0.0" resolved "https://registry.yarnpkg.com/original/-/original-1.0.0.tgz#9147f93fa1696d04be61e01bd50baeaca656bd3b" @@ -5504,6 +5755,10 @@ output-file-sync@^1.1.0: mkdirp "^0.5.1" object-assign "^4.1.0" +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + pako@~0.2.0: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -5589,6 +5844,10 @@ path-is-inside@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" +path-key@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + path-parse@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" @@ -6197,6 +6456,21 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" +prettier@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-0.21.0.tgz#5187ab95fdd9ca63dccf6217ed03b434d72771f8" + dependencies: + ast-types "0.9.4" + babel-code-frame "6.22.0" + babylon "6.15.0" + chalk "1.1.3" + esutils "2.0.2" + flow-parser "0.40.0" + get-stdin "5.0.1" + glob "7.1.1" + jest-validate "19.0.0" + minimist "1.2.0" + pretty-error@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.0.2.tgz#a7db19cbb529ca9f0af3d3a2f77d5caf8e5dec23" @@ -6210,6 +6484,12 @@ pretty-format@^18.1.0: dependencies: ansi-styles "^2.2.1" +pretty-format@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-19.0.0.tgz#56530d32acb98a3fa4851c4e2b9d37b420684c84" + dependencies: + ansi-styles "^3.0.0" + private@^0.1.6, private@~0.1.5: version "0.1.7" resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" @@ -6257,6 +6537,10 @@ prr@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" +pseudomap@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + public-encrypt@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" @@ -6969,6 +7253,12 @@ rx@2.3.24: version "2.3.24" resolved "https://registry.yarnpkg.com/rx/-/rx-2.3.24.tgz#14f950a4217d7e35daa71bbcbe58eff68ea4b2b7" +rxjs@^5.0.0-beta.11: + version "5.2.0" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.2.0.tgz#db537de8767c05fa73721587a29e0085307d318b" + dependencies: + symbol-observable "^1.0.1" + safe-buffer@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" @@ -7108,6 +7398,16 @@ shallowequal@0.2.x, shallowequal@^0.2.2: dependencies: lodash.keys "^3.1.2" +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + shelljs@^0.7.4, shelljs@^0.7.5: version "0.7.6" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.6.tgz#379cccfb56b91c8601e4793356eb5382924de9ad" @@ -7300,6 +7600,10 @@ stack-trace@0.0.x: version "0.0.9" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.9.tgz#a8f6eaeca90674c333e7c43953f275b451510695" +staged-git-files@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/staged-git-files/-/staged-git-files-0.0.4.tgz#d797e1b551ca7a639dec0237dc6eb4bb9be17d35" + "statuses@>= 1.3.1 < 2", statuses@~1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" @@ -7325,6 +7629,10 @@ stream-http@^2.3.1: to-arraybuffer "^1.0.0" xtend "^4.0.0" +stream-to-observable@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -7394,6 +7702,10 @@ strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" @@ -7430,7 +7742,7 @@ svgo@^0.7.0: sax "~1.2.1" whet.extend "~0.9.9" -symbol-observable@^1.0.2, symbol-observable@^1.0.4: +symbol-observable@^1.0.1, symbol-observable@^1.0.2, symbol-observable@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" @@ -7980,7 +8292,7 @@ which-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" -which@^1.0.5, which@^1.1.1, which@^1.2.1: +which@^1.0.5, which@^1.1.1, which@^1.2.1, which@^1.2.10, which@^1.2.11, which@^1.2.9: version "1.2.12" resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" dependencies: @@ -8107,6 +8419,10 @@ y18n@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" +yallist@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.0.0.tgz#306c543835f09ee1a4cb23b7bce9ab341c91cdd4" + yargs-parser@^4.2.0: version "4.2.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c"