From 1e76d8dcb907d1248ec7c4a536fc00f9d109d1a9 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Wed, 29 Mar 2017 01:23:16 -0700 Subject: [PATCH 001/202] Filter pin map by drawing rectangle --- .../visualizations/components/LeafletMap.jsx | 63 ++++++++++++++++++- .../visualizations/components/PinMap.jsx | 28 +++++++-- package.json | 1 + yarn.lock | 4 ++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/frontend/src/metabase/visualizations/components/LeafletMap.jsx b/frontend/src/metabase/visualizations/components/LeafletMap.jsx index e6e3d1bf109df..64d03521f66e4 100644 --- a/frontend/src/metabase/visualizations/components/LeafletMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletMap.jsx @@ -5,9 +5,14 @@ import MetabaseSettings from "metabase/lib/settings"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; +import "leaflet-draw"; import _ from "underscore"; +import { updateIn } from "icepick"; +import * as Query from "metabase/lib/query/query"; +import { mbqlEq } from "metabase/lib/query/util"; + export default class LeafletMap extends Component { componentDidMount() { try { @@ -16,7 +21,26 @@ export default class LeafletMap extends Component { const map = this.map = L.map(element, { scrollWheelZoom: false, minZoom: 2 - }) + }); + + const drawnItems = new L.FeatureGroup(); + map.addLayer(drawnItems); + const drawControl = this.drawControl = new L.Control.Draw({ + draw: { + rectangle: false, + polyline: false, + polygon: false, + circle: false, + marker: false + }, + edit: { + featureGroup: drawnItems, + edit: false, + remove: false + } + }); + map.addControl(drawControl); + map.on("draw:created", this.onFilter); map.setView([0,0], 8); @@ -53,6 +77,43 @@ export default class LeafletMap extends Component { } } + startFilter() { + this._filter = new L.Draw.Rectangle(this.map, this.drawControl.options.rectangle); + this._filter.enable(); + this.props.onFiltering(true); + } + stopFilter() { + this._filter && this._filter.disable(); + this.props.onFiltering(false); + } + onFilter = (e) => { + const bounds = e.layer.getBounds(); + + const { series: [{ card, data: { cols } }], settings, setCardAndRun } = this.props; + + const latitudeColumn = _.findWhere(cols, { name: settings["map.latitude_column"] }); + const longitudeColumn = _.findWhere(cols, { name: settings["map.longitude_column"] }); + + const filter = [ + "inside", + latitudeColumn.id, longitudeColumn.id, + bounds.getNorth(), bounds.getWest(), bounds.getSouth(), bounds.getEast() + ] + + setCardAndRun(updateIn(card, ["dataset_query", "query"], (query) => { + const index = _.findIndex(Query.getFilters(query), (filter) => + mbqlEq(filter[0], "inside") && filter[1] === latitudeColumn.id && filter[2] === longitudeColumn.id + ); + if (index >= 0) { + return Query.updateFilter(query, index, filter); + } else { + return Query.addFilter(query, filter); + } + })); + + this.props.onFiltering(false); + } + render() { const { className } = this.props; return ( diff --git a/frontend/src/metabase/visualizations/components/PinMap.jsx b/frontend/src/metabase/visualizations/components/PinMap.jsx index ff434eefc7799..0665507efe1ed 100644 --- a/frontend/src/metabase/visualizations/components/PinMap.jsx +++ b/frontend/src/metabase/visualizations/components/PinMap.jsx @@ -110,6 +110,7 @@ export default class PinMap extends Component<*, Props, State> { { Map ? this._map = map} className="absolute top left bottom right z1" onMapCenterChange={this.onMapCenterChange} onMapZoomChange={this.onMapZoomChange} @@ -118,13 +119,30 @@ export default class PinMap extends Component<*, Props, State> { zoom={zoom} points={points} bounds={bounds} + onFiltering={(filtering) => this.setState({ filtering })} /> : null } - { isEditing || !isDashboard ? -
- Save as default view -
- : null } +
+ { isEditing || !isDashboard ? +
+ Save as default view +
+ : null } + { !isDashboard && +
{ + if (!this.state.filtering && this._map && this._map.startFilter) { + this._map.startFilter(); + } else if (this.state.filtering && this._map && this._map.stopFilter) { + this._map.stopFilter(); + } + }} + > + { !this.state.filtering ? "Filter by rectange" : "Cancel filter" } +
+ } +
); } diff --git a/package.json b/package.json index ace8b699ed34d..a5d0fc0329d6e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "js-cookie": "^2.1.2", "jsrsasign": "^7.1.0", "leaflet": "^1.0.1", + "leaflet-draw": "^0.4.9", "moment": "2.14.1", "node-libs-browser": "^2.0.0", "normalizr": "^3.0.2", diff --git a/yarn.lock b/yarn.lock index 862aac640974a..d017488f2215f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4635,6 +4635,10 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" +leaflet-draw@^0.4.9: + version "0.4.9" + resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-0.4.9.tgz#44105088310f47e4856d5ede37d47ecfad0cf2d5" + leaflet@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.0.3.tgz#1f401b98b45c8192134c6c8d69686253805007c8" From d85fc478e616d5b790568a0afa88b99872437940 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Wed, 19 Apr 2017 13:59:18 -0700 Subject: [PATCH 002/202] Add column header actions and some tests --- bin/ci | 1 - .../src/metabase/meta/types/Visualization.js | 2 +- .../qb/components/actions/PivotByAction.jsx | 60 ++++++----- .../components/actions/PlotSegmentField.jsx | 16 +-- .../SummarizeBySegmentMetricAction.jsx | 44 ++++---- .../actions/UnderlyingDataAction.jsx | 20 ++-- .../actions/UnderlyingRecordsAction.jsx | 43 ++++---- .../qb/components/drill/CountByColumnDrill.js | 46 ++++++++ .../drill/CountByColumnDrill.spec.js | 37 +++++++ .../qb/components/drill/ObjectDetailDrill.jsx | 33 +++--- .../drill/ObjectDetailDrill.spec.js | 49 +++++++++ .../components/drill/PivotByCategoryDrill.jsx | 2 +- .../components/drill/PivotByLocationDrill.jsx | 2 +- .../qb/components/drill/PivotByTimeDrill.jsx | 2 +- .../qb/components/drill/QuickFilterDrill.jsx | 97 +++++++++-------- .../qb/components/drill/SortAction.jsx | 80 +++++++------- .../components/drill/SumColumnByTimeDrill.js | 57 ++++++++++ .../drill/SumColumnByTimeDrill.spec.js | 46 ++++++++ .../components/drill/SummarizeColumnDrill.js | 48 +++++++++ .../drill/SummarizeColumnDrill.spec.js | 28 +++++ .../components/drill/TimeseriesPivotDrill.jsx | 29 ++--- .../drill/UnderlyingRecordsDrill.jsx | 28 ++--- .../components/drill/__support__/fixtures.js | 100 ++++++++++++++++++ .../qb/components/modes/SegmentMode.jsx | 10 +- frontend/src/metabase/qb/lib/actions.js | 2 +- frontend/src/metabase/qb/lib/modes.js | 14 +-- package.json | 18 ++-- webpack.config.js | 3 +- 28 files changed, 686 insertions(+), 231 deletions(-) create mode 100644 frontend/src/metabase/qb/components/drill/CountByColumnDrill.js create mode 100644 frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js create mode 100644 frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js create mode 100644 frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js create mode 100644 frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js create mode 100644 frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js create mode 100644 frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js create mode 100644 frontend/src/metabase/qb/components/drill/__support__/fixtures.js diff --git a/bin/ci b/bin/ci index 4869c1cd4ae31..9bf6db3224697 100755 --- a/bin/ci +++ b/bin/ci @@ -49,7 +49,6 @@ node-5() { run_step lein eastwood run_step yarn run lint run_step yarn run test - run_step yarn run test-jest run_step yarn run flow } node-6() { diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index 0c4631b4373e4..3bc6b7e8c1812 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -4,7 +4,7 @@ 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 ActionCreator = (props: ClickActionProps) => ClickAction[] export type QueryMode = { name: string, diff --git a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx index fc4a488227804..027434a8f7289 100644 --- a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx +++ b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx @@ -20,7 +20,7 @@ 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 => { + ({ card, tableMetadata, clicked }: ClickActionProps): ClickAction[] => { const query = Card.getQuery(card); // Click target types: metric value @@ -31,7 +31,7 @@ export default (name: string, icon: string, fieldFilter: FieldFilter) => (clicked.value === undefined || clicked.column.source !== "aggregation")) ) { - return; + return []; } let dimensions = (clicked && clicked.dimensions) || []; @@ -61,33 +61,35 @@ export default (name: string, icon: string, fieldFilter: FieldFilter) => const customFieldOptions = Query.getExpressions(query); if (fieldOptions.count === 0) { - return null; + return []; } - return { - title: ( - - Pivot by - {" "} - {name.toLowerCase()} - - ), - icon: icon, - // eslint-disable-next-line react/display-name - popover: ( - { onChangeCardAndRun, onClose }: ClickActionPopoverProps - ) => ( - { - onChangeCardAndRun( - pivot(card, breakout, tableMetadata, dimensions) - ); - }} - onClose={onClose} - /> - ) - }; + return [ + { + title: ( + + Pivot by + {" "} + {name.toLowerCase()} + + ), + icon: icon, + // eslint-disable-next-line react/display-name + popover: ( + { onChangeCardAndRun, onClose }: ClickActionPopoverProps + ) => ( + { + onChangeCardAndRun( + pivot(card, breakout, tableMetadata, dimensions) + ); + }} + onClose={onClose} + /> + ) + } + ]; }; diff --git a/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx b/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx index 29ab9ce34a59a..ce060112ff419 100644 --- a/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx +++ b/frontend/src/metabase/qb/components/actions/PlotSegmentField.jsx @@ -7,13 +7,15 @@ import type { ClickActionProps } from "metabase/meta/types/Visualization"; -export default ({ card, tableMetadata }: ClickActionProps): ?ClickAction => { +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { if (card.display !== "table") { - return; + return []; } - return { - title: "Plot a field in this segment", - icon: "bar", - card: () => plotSegmentField(card) - }; + 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 index 97a53e6d458dc..22e8f88eb4cde 100644 --- a/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx +++ b/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx @@ -14,28 +14,32 @@ import type { ClickActionPopoverProps } from "metabase/meta/types/Visualization"; -export default ({ card, tableMetadata }: ClickActionProps): ?ClickAction => { +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { const query = Card.getQuery(card); if (!query) { - return; + return []; } - return { - title: "Summarize this segment", - icon: "funnel", // FIXME: icon - // eslint-disable-next-line react/display-name - popover: ({ onChangeCardAndRun, onClose }: ClickActionPopoverProps) => ( - { - onChangeCardAndRun( - summarize(card, aggregation, tableMetadata) - ); - onClose && onClose(); - }} - /> - ) - }; + return [ + { + title: "Summarize this segment", + icon: "funnel", // FIXME: icon + // eslint-disable-next-line react/display-name + popover: ( + { onChangeCardAndRun, onClose }: ClickActionPopoverProps + ) => ( + { + 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 index a45bc79fd1b34..7bdce877cca43 100644 --- a/frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx +++ b/frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx @@ -2,14 +2,20 @@ import { toUnderlyingData } from "metabase/qb/lib/actions"; -import type { ClickActionProps } from "metabase/meta/types/Visualization"; +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; -export default ({ card, tableMetadata }: ClickActionProps) => { +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { if (card.display !== "table" && card.display !== "scalar") { - return { - title: "View the underlying data", - icon: "table", - card: () => toUnderlyingData(card) - }; + return [ + { + title: "View the underlying data", + icon: "table", + card: () => toUnderlyingData(card) + } + ]; } + return []; }; diff --git a/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx b/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx index d1f2fa34527dc..7d251e63017ea 100644 --- a/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx +++ b/frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx @@ -6,28 +6,31 @@ 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"; +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; -export default ({ card, tableMetadata }: ClickActionProps) => { +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { const query = Card.getQuery(card); - if (!query) { - return; - } - if (!Query.isBareRows(query)) { - return { - title: ( - - View the underlying - {" "} - - {tableMetadata.display_name} + if (query && !Query.isBareRows(query)) { + return [ + { + title: ( + + View the underlying + {" "} + + {tableMetadata.display_name} + + {" "} + records - {" "} - records - - ), - icon: "table", - card: () => toUnderlyingRecords(card) - }; + ), + icon: "table", + card: () => toUnderlyingRecords(card) + } + ]; } + return []; }; diff --git a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.js b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.js new file mode 100644 index 0000000000000..7ddd2410ab9c5 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.js @@ -0,0 +1,46 @@ +/* @flow */ + +import React from "react"; + +import { + summarize, + pivot, + getFieldClauseFromCol +} from "metabase/qb/lib/actions"; +import * as Card from "metabase/meta/Card"; +import { isCategory } from "metabase/lib/schema_metadata"; + +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 !== "fields" || + !isCategory(clicked.column) + ) { + return []; + } + const { column } = clicked; + + return [ + { + title: Count of rows by {column.display_name}, + card: () => + pivot( + summarize(card, ["count"], tableMetadata), + getFieldClauseFromCol(column), + tableMetadata + ) + } + ]; +}; diff --git a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js new file mode 100644 index 0000000000000..deb66ac08fa0c --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js @@ -0,0 +1,37 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import CountByColumnDrill from "./CountByColumnDrill"; + +import { + card, + tableMetadata, + clickedCategoryHeader +} from "./__support__/fixtures"; + +describe("CountByColumnDrill", () => { + it("should not be valid for top level actions", () => { + expect(CountByColumnDrill({ card, tableMetadata })).toHaveLength(0); + }); + it("should be valid for click on numeric column header", () => { + expect( + CountByColumnDrill({ + card, + tableMetadata, + clicked: clickedCategoryHeader + }) + ).toHaveLength(1); + }); + it("should be return correct new card", () => { + const actions = CountByColumnDrill({ + card, + tableMetadata, + clicked: clickedCategoryHeader + }); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + aggregation: [["count"]], + breakout: [["field-id", 2]] + }); + expect(newCard.display).toEqual("bar"); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx index 0ffac33895732..bcfeb23628a2d 100644 --- a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx @@ -16,7 +16,7 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): ClickAction[] => { if ( !clicked || !clicked.column || @@ -24,7 +24,7 @@ export default ( !(isFK(clicked.column.special_type) || isPK(clicked.column.special_type)) ) { - return; + return []; } const value = clicked.value; @@ -39,20 +39,23 @@ export default ( } if (!field || !table) { - return; + return []; } - return { - title: ( - - View this - {" "} - - {singularize(stripId(recordType))} + return [ + { + title: ( + + View this + {" "} + + {singularize(stripId(recordType))} + - - ), - default: true, - card: () => drillRecord(tableMetadata.db_id, table.id, field.id, value) - }; + ), + default: true, + card: () => + drillRecord(tableMetadata.db_id, table.id, field.id, value) + } + ]; }; diff --git a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js new file mode 100644 index 0000000000000..4226586c84ded --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js @@ -0,0 +1,49 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import ObjectDetailDrill from "./ObjectDetailDrill"; + +import { + card, + tableMetadata, + clickedFloatValue, + clickedPKValue, + clickedFKValue +} from "./__support__/fixtures"; + +describe("ObjectDetailDrill", () => { + it("should not be valid non-PK cells", () => { + expect( + ObjectDetailDrill({ + card, + tableMetadata, + clicked: clickedFloatValue + }) + ).toHaveLength(0); + }); + it("should be return correct new card for PKs", () => { + const actions = ObjectDetailDrill({ + card, + tableMetadata, + clicked: clickedPKValue + }); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + source_table: 10, + filter: ["=", ["field-id", 4], 42] + }); + expect(newCard.display).toEqual("table"); + }); + it("should be return correct new card for FKs", () => { + const actions = ObjectDetailDrill({ + card, + tableMetadata, + clicked: clickedFKValue + }); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + source_table: 20, + filter: ["=", ["field-id", 25], 43] + }); + expect(newCard.display).toEqual("table"); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx b/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx index fdc7580a417f1..0fc7850f5a69c 100644 --- a/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/PivotByCategoryDrill.jsx @@ -9,6 +9,6 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): 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 index b365e0b955e53..ece628265ae16 100644 --- a/frontend/src/metabase/qb/components/drill/PivotByLocationDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/PivotByLocationDrill.jsx @@ -9,6 +9,6 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): 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 index 9d2c7969d1bb4..19faac0e515f9 100644 --- a/frontend/src/metabase/qb/components/drill/PivotByTimeDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/PivotByTimeDrill.jsx @@ -9,6 +9,6 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): 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 index e70a957822a69..5230d9e46889a 100644 --- a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx @@ -30,67 +30,76 @@ function getFiltersForColumn(column) { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): ClickAction[] => { if ( !clicked || !clicked.column || clicked.column.id == null || clicked.value == undefined ) { - return; + return []; } const { value, column } = clicked; if (isPK(column.special_type)) { - return null; + return []; } else if (isFK(column.special_type)) { - return { - title: ( - - View this - {" "} - {singularize(stripId(column.display_name))} - 's - {" "} - {pluralize(tableMetadata.display_name)} - - ), - card: () => filter(card, "=", column, value) - }; + return [ + { + title: ( + + View this + {" "} + {singularize(stripId(column.display_name))} + 's + {" "} + {pluralize(tableMetadata.display_name)} + + ), + card: () => filter(card, "=", column, value) + } + ]; } let operators = getFiltersForColumn(column); if (!operators || operators.length === 0) { - return; + return []; } - return { - title: ( - - Filter by this value - - ), - default: true, - popover({ onChangeCardAndRun, onClose }) { - return ( -
    - {operators && - operators.map(({ name, operator }) => ( -
  • { - onChangeCardAndRun( - filter(card, operator, column, value) - ); - }} - > - {name} -
  • - ))} -
- ); + return [ + { + title: ( + + Filter by this value + + ), + default: true, + popover({ onChangeCardAndRun, onClose }) { + return ( +
    + {operators && + operators.map(({ name, operator }) => ( +
  • { + onChangeCardAndRun( + filter( + card, + operator, + column, + value + ) + ); + }} + > + {name} +
  • + ))} +
+ ); + } } - }; + ]; }; diff --git a/frontend/src/metabase/qb/components/drill/SortAction.jsx b/frontend/src/metabase/qb/components/drill/SortAction.jsx index e37920c2ac68c..48f4228c6610a 100644 --- a/frontend/src/metabase/qb/components/drill/SortAction.jsx +++ b/frontend/src/metabase/qb/components/drill/SortAction.jsx @@ -13,7 +13,7 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): ClickAction[] => { const query = Card.getQuery(card); if ( @@ -23,51 +23,53 @@ export default ( clicked.value !== undefined || !clicked.column.source ) { - return; + return []; } const { column } = clicked; - return { - title: ( - - Sort by {column.display_name} - - ), - 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]; + return [ + { + title: ( + + Sort by {column.display_name} + + ), + 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 = ["aggregation", 0]; + field = column.id; } - } else { - field = column.id; - } - let sortClause = [field, "ascending"]; + 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"]; - } + 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] - ); + // set clause + return assocIn( + card, + ["dataset_query", "query", "order_by"], + [sortClause] + ); + } } - }; + ]; }; diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js new file mode 100644 index 0000000000000..215ab6d8c2b80 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js @@ -0,0 +1,57 @@ +/* @flow */ + +import React from "react"; + +import { + pivot, + summarize, + getFieldClauseFromCol +} from "metabase/qb/lib/actions"; +import * as Card from "metabase/meta/Card"; +import { isNumeric, isDate } from "metabase/lib/schema_metadata"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ClickAction[] => { + const query = Card.getQuery(card); + + const dateField = tableMetadata.fields.filter(isDate)[0]; + + if ( + !dateField || + !query || + !clicked || + !clicked.column || + clicked.value !== undefined || + !isNumeric(clicked.column) + ) { + return []; + } + const { column } = clicked; + + return [ + { + title: Sum of {column.display_name} by Time, + card: () => + pivot( + summarize( + card, + ["sum", getFieldClauseFromCol(column)], + tableMetadata + ), + [ + "datetime-field", + getFieldClauseFromCol(dateField), + "as", + "day" + ], + tableMetadata + ) + } + ]; +}; diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js new file mode 100644 index 0000000000000..b375d12bc5635 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js @@ -0,0 +1,46 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import SumColumnByTimeDrill from "./SumColumnByTimeDrill"; + +import { + card, + tableMetadata, + clickedFloatHeader +} from "./__support__/fixtures"; + +describe("SumColumnByTimeDrill", () => { + it("should not be valid for top level actions", () => { + expect(SumColumnByTimeDrill({ card, tableMetadata })).toHaveLength(0); + }); + it("should not be valid if there is no time field", () => { + expect( + SumColumnByTimeDrill({ + card, + tableMetadata: { fields: [] }, + clicked: clickedFloatHeader + }) + ).toHaveLength(0); + }); + it("should be valid for click on numeric column header", () => { + expect( + SumColumnByTimeDrill({ + card, + tableMetadata, + clicked: clickedFloatHeader + }) + ).toHaveLength(1); + }); + it("should be return correct new card", () => { + const actions = SumColumnByTimeDrill({ + card, + tableMetadata, + clicked: clickedFloatHeader + }); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + aggregation: [["sum", ["field-id", 1]]], + breakout: [["datetime-field", ["field-id", 3], "as", "day"]] + }); + expect(newCard.display).toEqual("line"); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js new file mode 100644 index 0000000000000..559b7d25a1cf2 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js @@ -0,0 +1,48 @@ +/* @flow */ + +import React from "react"; + +import { summarize, getFieldClauseFromCol } from "metabase/qb/lib/actions"; +import * as Card from "metabase/meta/Card"; +import { isNumeric } from "metabase/lib/schema_metadata"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +const AGGREGATIONS = { + min: "Minimum", + max: "Maximum", + avg: "Average", + sum: "Sum", + distinct: "Distinct Values" +}; + +export default ( + { card, tableMetadata, clicked }: ClickActionProps +): ClickAction[] => { + const query = Card.getQuery(card); + + if ( + !query || + !clicked || + !clicked.column || + clicked.value !== undefined || + clicked.column.source !== "fields" || + !isNumeric(clicked.column) + ) { + return []; + } + const { column } = clicked; + + return Object.entries(AGGREGATIONS).map(([aggregation, name]) => ({ + title: {name} of {column.display_name}, + card: () => + summarize( + card, + [aggregation, getFieldClauseFromCol(column)], + tableMetadata + ) + })); +}; diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js new file mode 100644 index 0000000000000..4d907a2bd1dbb --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js @@ -0,0 +1,28 @@ +/* eslint-disable */ + +import SummarizeColumnDrill from "./SummarizeColumnDrill"; + +import { + card, + tableMetadata, + clickedFloatHeader +} from "./__support__/fixtures"; + +describe("SummarizeColumnDrill", () => { + it("should not be valid for top level actions", () => { + expect(SummarizeColumnDrill({ card, tableMetadata })).toHaveLength(0); + }); + it("should be valid for click on numeric column header", () => { + const actions = SummarizeColumnDrill({ + card, + tableMetadata, + clicked: clickedFloatHeader + }); + expect(actions.length).toEqual(5); + let newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + aggregation: [["min", ["field-id", 1]]] + }); + expect(newCard.display).toEqual("scalar"); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx index da69baae8738f..cbff49daf15cd 100644 --- a/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx @@ -11,23 +11,26 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): ClickAction[] => { const dimensions = (clicked && clicked.dimensions) || []; const drilldown = drillDownForDimensions(dimensions); if (!drilldown) { - return; + return []; } - return { - title: ( - - Drill into this - {" "} - - {drilldown.name} + return [ + { + title: ( + + Drill into this + {" "} + + {drilldown.name} + - - ), - card: () => pivot(card, drilldown.breakout, tableMetadata, dimensions) - }; + ), + 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 index 1f3c0d8052d73..13768c4b5ab4f 100644 --- a/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx @@ -13,25 +13,27 @@ import type { export default ( { card, tableMetadata, clicked }: ClickActionProps -): ?ClickAction => { +): ClickAction[] => { const dimensions = (clicked && clicked.dimensions) || []; if (!clicked || dimensions.length === 0) { - return; + 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: ( - - View {inflect("these", count, "this", "these")} - {" "} - - {inflect(tableMetadata.display_name, count)} + return [ + { + title: ( + + View {inflect("these", count, "this", "these")} + {" "} + + {inflect(tableMetadata.display_name, count)} + - - ), - card: () => drillUnderlyingRecords(card, dimensions) - }; + ), + card: () => drillUnderlyingRecords(card, dimensions) + } + ]; }; diff --git a/frontend/src/metabase/qb/components/drill/__support__/fixtures.js b/frontend/src/metabase/qb/components/drill/__support__/fixtures.js new file mode 100644 index 0000000000000..2c88acf0b1834 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/__support__/fixtures.js @@ -0,0 +1,100 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import { TYPE } from "metabase/lib/types"; + +const FLOAT_FIELD = { + id: 1, + display_name: "Mock Float Field", + base_type: TYPE.Float +}; + +const CATEGORY_FIELD = { + id: 2, + display_name: "Mock Category Field", + base_type: TYPE.Text, + special_type: TYPE.Category +}; + +const DATE_FIELD = { + id: 3, + display_name: "Mock Date Field", + base_type: TYPE.DateTime +}; + +const PK_FIELD = { + id: 4, + display_name: "Mock PK Field", + base_type: TYPE.Integer, + special_type: TYPE.PK +}; + +const foreignTableMetadata = { + id: 20, + db_id: 100, + display_name: "Mock Foreign Table", + fields: [] +}; + +const FK_FIELD = { + id: 5, + display_name: "Mock FK Field", + base_type: TYPE.Integer, + special_type: TYPE.FK, + target: { + id: 25, + table_id: foreignTableMetadata.id, + table: foreignTableMetadata + } +}; + +export const tableMetadata = { + id: 10, + db_id: 100, + display_name: "Mock Table", + fields: [FLOAT_FIELD, CATEGORY_FIELD, DATE_FIELD, PK_FIELD, FK_FIELD] +}; + +export const card = { + dataset_query: { + type: "query", + query: {} + } +}; + +export const clickedFloatHeader = { + column: { + ...FLOAT_FIELD, + source: "fields" + } +}; + +export const clickedCategoryHeader = { + column: { + ...CATEGORY_FIELD, + source: "fields" + } +}; + +export const clickedFloatValue = { + column: { + ...CATEGORY_FIELD, + source: "fields" + }, + value: 1234 +}; + +export const clickedPKValue = { + column: { + ...PK_FIELD, + source: "fields" + }, + value: 42 +}; + +export const clickedFKValue = { + column: { + ...FK_FIELD, + source: "fields" + }, + value: 43 +}; diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx index c302cc83f025d..b3d8cccf916d7 100644 --- a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -5,6 +5,9 @@ import { DEFAULT_DRILLS } from "../drill"; import SummarizeBySegmentMetricAction from "../actions/SummarizeBySegmentMetricAction"; +import SummarizeColumnDrill from "../drill/SummarizeColumnDrill"; +import SumColumnByTimeDrill from "../drill/SumColumnByTimeDrill"; +import CountByColumnDrill from "../drill/CountByColumnDrill"; // import PlotSegmentField from "../actions/PlotSegmentField"; import type { QueryMode } from "metabase/meta/types/Visualization"; @@ -17,7 +20,12 @@ const SegmentMode: QueryMode = { // commenting this out until we sort out viz settings in QB2 // PlotSegmentField ], - drills: [...DEFAULT_DRILLS] + drills: [ + ...DEFAULT_DRILLS, + SummarizeColumnDrill, + SumColumnByTimeDrill, + CountByColumnDrill + ] }; export default SegmentMode; diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js index c2667e9073838..44ce4a800701d 100644 --- a/frontend/src/metabase/qb/lib/actions.js +++ b/frontend/src/metabase/qb/lib/actions.js @@ -161,7 +161,7 @@ export const drillRecord = (databaseId, tableId, fieldId, value) => { const newCard = startNewCard("query", databaseId, tableId); newCard.dataset_query.query = Query.addFilter(newCard.dataset_query.query, [ "=", - fieldId, + ["field-id", fieldId], value ]); return newCard; diff --git a/frontend/src/metabase/qb/lib/modes.js b/frontend/src/metabase/qb/lib/modes.js index aed8f3f378ccd..b988062e09a47 100644 --- a/frontend/src/metabase/qb/lib/modes.js +++ b/frontend/src/metabase/qb/lib/modes.js @@ -87,9 +87,10 @@ export const getModeActions = ( ): ClickAction[] => { if (mode && card && tableMetadata) { const props: ClickActionProps = { card, tableMetadata }; - return mode.actions - .map(actionCreator => actionCreator(props)) - .filter(action => action); + // flatten array of arrays + return [].concat( + ...mode.actions.map(actionCreator => actionCreator(props)) + ); } return []; }; @@ -102,9 +103,10 @@ export const getModeDrills = ( ): ClickAction[] => { if (mode && card && tableMetadata && clicked) { const props: ClickActionProps = { card, tableMetadata, clicked }; - return mode.drills - .map(actionCreator => actionCreator(props)) - .filter(action => action); + // flatten array of arrays + return [].concat( + ...mode.drills.map(actionCreator => actionCreator(props)) + ); } return []; }; diff --git a/package.json b/package.json index 298fee0dd7351..882c43a8f7506 100644 --- a/package.json +++ b/package.json @@ -146,10 +146,13 @@ "dev": "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'", "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)", + "lint-prettier": "prettier --tab-width 4 -l 'frontend/src/metabase/{qb,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", + "test": "yarn run test-jest && yarn run test-karma", + "test-karma": "karma start frontend/test/karma.conf.js --single-run", + "test-karma-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", + "test-jest": "jest", + "test-jest-watch": "jest --watch", "test-e2e": "JASMINE_CONFIG_PATH=./frontend/test/e2e/support/jasmine.json jasmine", "test-e2e-dev": "./frontend/test/e2e-with-persistent-browser.js", "test-e2e-sauce": "USE_SAUCE=true yarn run test-e2e", @@ -159,15 +162,10 @@ "start": "yarn run build && lein ring server", "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" + "prettier": "prettier --tab-width 4 --write 'frontend/src/metabase/{qb,new_question}/**/*.js*'" }, "lint-staged": { - "frontend/src/metabase/qb/**/*.js*": [ - "prettier --tab-width 4 --write", - "git add" - ], - "frontend/src/metabase/new_question/**/*.js*": [ + "frontend/src/metabase/{qb,new_question}/**/*.js*": [ "prettier --tab-width 4 --write", "git add" ] diff --git a/webpack.config.js b/webpack.config.js index 7a1591996fcc0..c6fc2605722b4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -134,7 +134,8 @@ var config = module.exports = { globOptions: { ignore: [ "**/types/*.js", - "**/*.spec.*" + "**/*.spec.*", + "**/__support__/**" ] } }), From 0ba389e020d03e80d1b8c5a7e42edb9e78852947 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Wed, 19 Apr 2017 14:42:15 -0700 Subject: [PATCH 003/202] Add common metrics action --- frontend/src/metabase/meta/types/Metadata.js | 4 +- frontend/src/metabase/meta/types/Metric.js | 13 +++++ frontend/src/metabase/meta/types/Query.js | 9 +-- .../{drill => }/__support__/fixtures.js | 0 .../actions/CommonMetricsAction.jsx | 26 +++++++++ .../actions/CommonMetricsAction.spec.js | 57 +++++++++++++++++++ .../drill/CountByColumnDrill.spec.js | 3 +- .../drill/ObjectDetailDrill.spec.js | 4 +- .../drill/SumColumnByTimeDrill.spec.js | 12 +--- .../drill/SummarizeColumnDrill.spec.js | 2 +- .../qb/components/modes/SegmentMode.jsx | 2 + webpack.config.js | 2 +- 12 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 frontend/src/metabase/meta/types/Metric.js rename frontend/src/metabase/qb/components/{drill => }/__support__/fixtures.js (100%) create mode 100644 frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx create mode 100644 frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js diff --git a/frontend/src/metabase/meta/types/Metadata.js b/frontend/src/metabase/meta/types/Metadata.js index 4fb281711ba44..0f81b27617804 100644 --- a/frontend/src/metabase/meta/types/Metadata.js +++ b/frontend/src/metabase/meta/types/Metadata.js @@ -5,6 +5,7 @@ import type { Table } from "metabase/meta/types/Table"; import type { Field } from "metabase/meta/types/Field"; import type { Segment } from "metabase/meta/types/Segment"; +import type { Metric } from "metabase/meta/types/Metric"; export type FieldValue = { name: string, @@ -50,8 +51,9 @@ export type BreakoutOptions = { } export type TableMetadata = Table & { - segments: Segment[], fields: FieldMetadata[], + segments: Segment[], + metrics: Metric[], aggregation_options: AggregationOption[], breakout_options: BreakoutOptions } diff --git a/frontend/src/metabase/meta/types/Metric.js b/frontend/src/metabase/meta/types/Metric.js new file mode 100644 index 0000000000000..ee5146bed28a0 --- /dev/null +++ b/frontend/src/metabase/meta/types/Metric.js @@ -0,0 +1,13 @@ +/* @flow */ + +import type { TableId } from "./Table"; + +export type MetricId = number; + +// TODO: incomplete +export type Metric = { + name: string, + id: MetricId, + table_id: TableId, + is_active: bool +}; diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js index 8f4ab74316c24..111c96db3e8b7 100644 --- a/frontend/src/metabase/meta/types/Query.js +++ b/frontend/src/metabase/meta/types/Query.js @@ -3,10 +3,9 @@ import type { TableId } from "./Table"; import type { FieldId } from "./Field"; import type { SegmentId } from "./Segment"; +import type { MetricId } from "./Metric"; import type { ParameterType } from "./Dashboard"; -export type MetricId = number; - export type ExpressionName = string; export type StringLiteral = string; @@ -76,7 +75,8 @@ type StdDevAgg = ["stddev", ConcreteField]; type SumAgg = ["sum", ConcreteField]; type MinAgg = ["min", ConcreteField]; type MaxAgg = ["max", ConcreteField]; -type MetricAgg = ["metric", MetricId]; +// NOTE: currently the backend expects METRIC to be uppercase +type MetricAgg = ["METRIC", MetricId]; export type BreakoutClause = Array; export type Breakout = @@ -114,7 +114,8 @@ export type NotNullFilter = ["not-null", ConcreteField]; export type InsideFilter = ["inside", ConcreteField, ConcreteField, NumericLiteral, NumericLiteral, NumericLiteral, NumericLiteral]; export type TimeIntervalFilter = ["time-interval", ConcreteField, RelativeDatetimePeriod, RelativeDatetimeUnit]; -export type SegmentFilter = ["segment", SegmentId]; +// NOTE: currently the backend expects SEGMENT to be uppercase +export type SegmentFilter = ["SEGMENT", SegmentId]; export type OrderByClause = Array; export type OrderBy = ["asc"|"desc", Field]; diff --git a/frontend/src/metabase/qb/components/drill/__support__/fixtures.js b/frontend/src/metabase/qb/components/__support__/fixtures.js similarity index 100% rename from frontend/src/metabase/qb/components/drill/__support__/fixtures.js rename to frontend/src/metabase/qb/components/__support__/fixtures.js diff --git a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx new file mode 100644 index 0000000000000..4042ccec84e40 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx @@ -0,0 +1,26 @@ +/* @flow */ + +import React from "react"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +import * as Card from "metabase/lib/card"; +import * as Query from "metabase/lib/query/query"; +import { chain } from "icepick"; + +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { + return tableMetadata.metrics.slice(0, 5).map(metric => ({ + title: View {metric.name}, + card: () => + chain( + Card.startNewCard("query", tableMetadata.db_id, metric.table_id) + ) + .updateIn(["dataset_query", "query"], query => + Query.addAggregation(query, ["METRIC", metric.id])) + .assoc("display", "scalar") + .value() + })); +}; diff --git a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js new file mode 100644 index 0000000000000..db392727a3242 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js @@ -0,0 +1,57 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import CommonMetricsAction from "./CommonMetricsAction"; + +import { card, tableMetadata } from "../__support__/fixtures"; + +const mockMetric = { + id: 123, + table_id: 234, + name: "Mock Metric" +}; + +const tableMetadata0Metrics = { ...tableMetadata, metrics: [] }; +const tableMetadata1Metric = { ...tableMetadata, metrics: [mockMetric] }; +const tableMetadata6Metrics = { + ...tableMetadata, + metrics: [ + mockMetric, + mockMetric, + mockMetric, + mockMetric, + mockMetric, + mockMetric + ] +}; + +describe("CommonMetricsAction", () => { + it("should not be valid if the table has no metrics", () => { + expect( + CommonMetricsAction({ + card, + tableMetadata: tableMetadata0Metrics + }) + ).toHaveLength(0); + }); + it("should return a scalar card for the metric", () => { + const actions = CommonMetricsAction({ + card, + tableMetadata: tableMetadata1Metric + }); + expect(actions).toHaveLength(1); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + source_table: 234, + aggregation: [["METRIC", 123]] + }); + expect(newCard.display).toEqual("scalar"); + }); + it("should only return up to 5 actions", () => { + expect( + CommonMetricsAction({ + card, + tableMetadata: tableMetadata6Metrics + }) + ).toHaveLength(5); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js index deb66ac08fa0c..9cc154b786497 100644 --- a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js @@ -6,7 +6,7 @@ import { card, tableMetadata, clickedCategoryHeader -} from "./__support__/fixtures"; +} from "../__support__/fixtures"; describe("CountByColumnDrill", () => { it("should not be valid for top level actions", () => { @@ -27,6 +27,7 @@ describe("CountByColumnDrill", () => { tableMetadata, clicked: clickedCategoryHeader }); + expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ aggregation: [["count"]], diff --git a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js index 4226586c84ded..8029dc1463e02 100644 --- a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.spec.js @@ -8,7 +8,7 @@ import { clickedFloatValue, clickedPKValue, clickedFKValue -} from "./__support__/fixtures"; +} from "../__support__/fixtures"; describe("ObjectDetailDrill", () => { it("should not be valid non-PK cells", () => { @@ -26,6 +26,7 @@ describe("ObjectDetailDrill", () => { tableMetadata, clicked: clickedPKValue }); + expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ source_table: 10, @@ -39,6 +40,7 @@ describe("ObjectDetailDrill", () => { tableMetadata, clicked: clickedFKValue }); + expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ source_table: 20, diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js index b375d12bc5635..322eb1c91e04b 100644 --- a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js @@ -6,7 +6,7 @@ import { card, tableMetadata, clickedFloatHeader -} from "./__support__/fixtures"; +} from "../__support__/fixtures"; describe("SumColumnByTimeDrill", () => { it("should not be valid for top level actions", () => { @@ -21,21 +21,13 @@ describe("SumColumnByTimeDrill", () => { }) ).toHaveLength(0); }); - it("should be valid for click on numeric column header", () => { - expect( - SumColumnByTimeDrill({ - card, - tableMetadata, - clicked: clickedFloatHeader - }) - ).toHaveLength(1); - }); it("should be return correct new card", () => { const actions = SumColumnByTimeDrill({ card, tableMetadata, clicked: clickedFloatHeader }); + expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ aggregation: [["sum", ["field-id", 1]]], diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js index 4d907a2bd1dbb..48b6ea1d473d5 100644 --- a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js @@ -6,7 +6,7 @@ import { card, tableMetadata, clickedFloatHeader -} from "./__support__/fixtures"; +} from "../__support__/fixtures"; describe("SummarizeColumnDrill", () => { it("should not be valid for top level actions", () => { diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx index b3d8cccf916d7..5e84dd5d9b372 100644 --- a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -5,6 +5,7 @@ import { DEFAULT_DRILLS } from "../drill"; import SummarizeBySegmentMetricAction from "../actions/SummarizeBySegmentMetricAction"; +import CommonMetricsAction from "../actions/CommonMetricsAction"; import SummarizeColumnDrill from "../drill/SummarizeColumnDrill"; import SumColumnByTimeDrill from "../drill/SumColumnByTimeDrill"; import CountByColumnDrill from "../drill/CountByColumnDrill"; @@ -16,6 +17,7 @@ const SegmentMode: QueryMode = { name: "segment", actions: [ ...DEFAULT_ACTIONS, + CommonMetricsAction, SummarizeBySegmentMetricAction // commenting this out until we sort out viz settings in QB2 // PlotSegmentField diff --git a/webpack.config.js b/webpack.config.js index c6fc2605722b4..06fb7858a9ded 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -135,7 +135,7 @@ var config = module.exports = { ignore: [ "**/types/*.js", "**/*.spec.*", - "**/__support__/**" + "**/__support__/*.js" ] } }), From 4a57c3845ae447e118d8d5fae70e108902eb1a8b Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 20 Apr 2017 12:40:31 -0700 Subject: [PATCH 004/202] Add CountByTimeAction, cleanup CommonMetricsAction, misc --- .../qb/components/__support__/fixtures.js | 4 ++- .../actions/CommonMetricsAction.jsx | 13 ++------ .../actions/CommonMetricsAction.spec.js | 4 +-- .../components/actions/CountByTimeAction.jsx | 30 +++++++++++++++++ .../actions/CountByTimeAction.spec.js | 33 +++++++++++++++++++ .../drill/CountByColumnDrill.spec.js | 1 + .../drill/SumColumnByTimeDrill.spec.js | 1 + .../drill/SummarizeColumnDrill.spec.js | 1 + .../qb/components/modes/SegmentMode.jsx | 2 ++ frontend/src/metabase/qb/lib/actions.js | 11 +++++++ 10 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 frontend/src/metabase/qb/components/actions/CountByTimeAction.jsx create mode 100644 frontend/src/metabase/qb/components/actions/CountByTimeAction.spec.js diff --git a/frontend/src/metabase/qb/components/__support__/fixtures.js b/frontend/src/metabase/qb/components/__support__/fixtures.js index 2c88acf0b1834..a655f9c1a2d37 100644 --- a/frontend/src/metabase/qb/components/__support__/fixtures.js +++ b/frontend/src/metabase/qb/components/__support__/fixtures.js @@ -57,7 +57,9 @@ export const tableMetadata = { export const card = { dataset_query: { type: "query", - query: {} + query: { + source_table: 10 + } } }; diff --git a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx index 4042ccec84e40..1a6db1e3dab12 100644 --- a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx +++ b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx @@ -7,20 +7,11 @@ import type { ClickActionProps } from "metabase/meta/types/Visualization"; -import * as Card from "metabase/lib/card"; -import * as Query from "metabase/lib/query/query"; -import { chain } from "icepick"; +import { summarize } from "metabase/qb/lib/actions"; export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { return tableMetadata.metrics.slice(0, 5).map(metric => ({ title: View {metric.name}, - card: () => - chain( - Card.startNewCard("query", tableMetadata.db_id, metric.table_id) - ) - .updateIn(["dataset_query", "query"], query => - Query.addAggregation(query, ["METRIC", metric.id])) - .assoc("display", "scalar") - .value() + card: () => summarize(card, ["METRIC", metric.id], tableMetadata) })); }; diff --git a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js index db392727a3242..d06e39902b8c4 100644 --- a/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js +++ b/frontend/src/metabase/qb/components/actions/CommonMetricsAction.spec.js @@ -6,7 +6,7 @@ import { card, tableMetadata } from "../__support__/fixtures"; const mockMetric = { id: 123, - table_id: 234, + table_id: 10, name: "Mock Metric" }; @@ -41,7 +41,7 @@ describe("CommonMetricsAction", () => { expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ - source_table: 234, + source_table: 10, aggregation: [["METRIC", 123]] }); expect(newCard.display).toEqual("scalar"); diff --git a/frontend/src/metabase/qb/components/actions/CountByTimeAction.jsx b/frontend/src/metabase/qb/components/actions/CountByTimeAction.jsx new file mode 100644 index 0000000000000..2444b60f511fc --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/CountByTimeAction.jsx @@ -0,0 +1,30 @@ +/* @flow */ + +import React from "react"; + +import type { + ClickAction, + ClickActionProps +} from "metabase/meta/types/Visualization"; + +import { isDate } from "metabase/lib/schema_metadata"; +import { summarize, breakout } from "metabase/qb/lib/actions"; + +export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { + const dateField = tableMetadata.fields.filter(isDate)[0]; + if (!dateField) { + return []; + } + + return [ + { + title: Count of rows by time, + card: () => + breakout( + summarize(card, ["count"], tableMetadata), + ["datetime-field", ["field-id", dateField.id], "as", "day"], + tableMetadata + ) + } + ]; +}; diff --git a/frontend/src/metabase/qb/components/actions/CountByTimeAction.spec.js b/frontend/src/metabase/qb/components/actions/CountByTimeAction.spec.js new file mode 100644 index 0000000000000..9a73656c7ef43 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/CountByTimeAction.spec.js @@ -0,0 +1,33 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import CountByTimeAction from "./CountByTimeAction"; + +import { card, tableMetadata } from "../__support__/fixtures"; + +const tableMetadata0TimeFields = { ...tableMetadata, fields: [] }; +const tableMetadata1TimeField = tableMetadata; + +describe("CountByTimeAction", () => { + it("should not be valid if the table has no metrics", () => { + expect( + CountByTimeAction({ + card, + tableMetadata: tableMetadata0TimeFields + }) + ).toHaveLength(0); + }); + it("should return a scalar card for the metric", () => { + const actions = CountByTimeAction({ + card, + tableMetadata: tableMetadata1TimeField + }); + expect(actions).toHaveLength(1); + const newCard = actions[0].card(); + expect(newCard.dataset_query.query).toEqual({ + source_table: 10, + aggregation: [["count"]], + breakout: [["datetime-field", ["field-id", 3], "as", "day"]] + }); + expect(newCard.display).toEqual("line"); + }); +}); diff --git a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js index 9cc154b786497..5b7eff3a104e9 100644 --- a/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/CountByColumnDrill.spec.js @@ -30,6 +30,7 @@ describe("CountByColumnDrill", () => { expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ + source_table: 10, aggregation: [["count"]], breakout: [["field-id", 2]] }); diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js index 322eb1c91e04b..12ea59e124e5f 100644 --- a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js @@ -30,6 +30,7 @@ describe("SumColumnByTimeDrill", () => { expect(actions).toHaveLength(1); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ + source_table: 10, aggregation: [["sum", ["field-id", 1]]], breakout: [["datetime-field", ["field-id", 3], "as", "day"]] }); diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js index 48b6ea1d473d5..d58b6a7907864 100644 --- a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.spec.js @@ -21,6 +21,7 @@ describe("SummarizeColumnDrill", () => { expect(actions.length).toEqual(5); let newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ + source_table: 10, aggregation: [["min", ["field-id", 1]]] }); expect(newCard.display).toEqual("scalar"); diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx index 5e84dd5d9b372..1f67a61189476 100644 --- a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -6,6 +6,7 @@ import { DEFAULT_DRILLS } from "../drill"; import SummarizeBySegmentMetricAction from "../actions/SummarizeBySegmentMetricAction"; import CommonMetricsAction from "../actions/CommonMetricsAction"; +import CountByTimeAction from "../actions/CountByTimeAction"; import SummarizeColumnDrill from "../drill/SummarizeColumnDrill"; import SumColumnByTimeDrill from "../drill/SumColumnByTimeDrill"; import CountByColumnDrill from "../drill/CountByColumnDrill"; @@ -18,6 +19,7 @@ const SegmentMode: QueryMode = { actions: [ ...DEFAULT_ACTIONS, CommonMetricsAction, + CountByTimeAction, SummarizeBySegmentMetricAction // commenting this out until we sort out viz settings in QB2 // PlotSegmentField diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js index 44ce4a800701d..d8b3e973be6de 100644 --- a/frontend/src/metabase/qb/lib/actions.js +++ b/frontend/src/metabase/qb/lib/actions.js @@ -185,6 +185,17 @@ export const summarize = (card, aggregation, tableMetadata) => { return newCard; }; +export const breakout = (card, breakout, tableMetadata) => { + const newCard = startNewCard("query"); + newCard.dataset_query = card.dataset_query; + newCard.dataset_query.query = Query.addBreakout( + newCard.dataset_query.query, + breakout + ); + guessVisualization(newCard, tableMetadata); + return newCard; +}; + export const pivot = ( card: CardObject, breakout, From 20d6849e661dc5ea98b65df046a953009086f9bc Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Fri, 21 Apr 2017 00:23:29 -0700 Subject: [PATCH 005/202] Cleanup redux/metadata, add selectors/metadata --- .../src/metabase/admin/datamodel/datamodel.js | 2 +- .../metabase/admin/permissions/selectors.js | 10 +- .../dashboard/components/DashCard.jsx | 6 + .../dashboard/containers/DashboardApp.jsx | 3 +- frontend/src/metabase/dashboard/selectors.js | 14 +- frontend/src/metabase/lib/query.js | 6 +- frontend/src/metabase/lib/schema_metadata.js | 6 +- frontend/src/metabase/lib/table.js | 4 +- frontend/src/metabase/meta/types/Database.js | 37 +++- frontend/src/metabase/meta/types/Dataset.js | 4 +- frontend/src/metabase/meta/types/Field.js | 38 +++- frontend/src/metabase/meta/types/Metadata.js | 54 +++-- frontend/src/metabase/meta/types/Segment.js | 3 + frontend/src/metabase/meta/types/Table.js | 45 +++- frontend/src/metabase/meta/types/index.js | 3 + .../src/metabase/query_builder/actions.js | 5 +- .../components/filters/FilterPopover.jsx | 4 +- .../query_builder/containers/QueryBuilder.jsx | 3 + .../src/metabase/query_builder/selectors.js | 17 +- frontend/src/metabase/redux/metadata.js | 208 ++++++++---------- .../ReferenceGettingStartedGuide.jsx | 22 +- frontend/src/metabase/reference/selectors.js | 65 ++++-- frontend/src/metabase/schema.js | 34 +++ frontend/src/metabase/selectors/metadata.js | 154 ++++++++++++- 24 files changed, 530 insertions(+), 217 deletions(-) create mode 100644 frontend/src/metabase/meta/types/index.js create mode 100644 frontend/src/metabase/schema.js diff --git a/frontend/src/metabase/admin/datamodel/datamodel.js b/frontend/src/metabase/admin/datamodel/datamodel.js index 0dafaacccd0e8..43f3576c3e8e2 100644 --- a/frontend/src/metabase/admin/datamodel/datamodel.js +++ b/frontend/src/metabase/admin/datamodel/datamodel.js @@ -124,7 +124,7 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { try { // make sure we don't send all the computed metadata let slimField = { ...field }; - slimField = _.omit(slimField, "operators_lookup", "valid_operators", "values"); + slimField = _.omit(slimField, "operators_lookup", "operators", "values"); // update the field let updatedField = await MetabaseApi.field_update(slimField); diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index efaad73d99df7..fbdc3f1f5d049 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -33,7 +33,7 @@ const getOriginalPermissions = (state) => state.admin.permissions.originalPermis const getDatabaseId = (state, props) => props.params.databaseId ? parseInt(props.params.databaseId) : null const getSchemaName = (state, props) => props.params.schemaName -const getMetadata = createSelector( +const getMeta = createSelector( [(state) => state.admin.permissions.databases], (databases) => databases && new Metadata(databases) ); @@ -204,7 +204,7 @@ const OPTION_COLLECTION_READ = { }; export const getTablesPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, getDatabaseId, getSchemaName, + getMeta, getGroups, getPermissions, getDatabaseId, getSchemaName, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, databaseId: DatabaseId, schemaName: SchemaName) => { const database = metadata && metadata.database(databaseId); @@ -264,7 +264,7 @@ export const getTablesPermissionsGrid = createSelector( ); export const getSchemasPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, getDatabaseId, + getMeta, getGroups, getPermissions, getDatabaseId, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, databaseId: DatabaseId) => { const database = metadata && metadata.database(databaseId); @@ -324,7 +324,7 @@ export const getSchemasPermissionsGrid = createSelector( ); export const getDatabasesPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, + getMeta, getGroups, getPermissions, (metadata: Metadata, groups: Array, permissions: GroupsPermissions) => { if (!groups || !permissions || !metadata) { return null; @@ -469,7 +469,7 @@ export const getCollectionsPermissionsGrid = createSelector( export const getDiff = createSelector( - getMetadata, getGroups, getPermissions, getOriginalPermissions, + getMeta, getGroups, getPermissions, getOriginalPermissions, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, originalPermissions: GroupsPermissions) => diffPermissions(permissions, originalPermissions, groups, metadata) ); diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index 60bf51b52dcb4..b632dc96da266 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -24,6 +24,12 @@ const HEADER_ACTION_STYLE = { padding: 4 }; +// const mapStateToProps = (state, props) => ({ +// }) +// const mapDispatchToProps = { +// } +// +// @connect(mapStateToProps, mapDispatchToProps) export default class DashCard extends Component { static propTypes = { dashcard: PropTypes.object.isRequired, diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index 71cb723187c1d..fa643613ccb74 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -10,7 +10,8 @@ import Dashboard from "../components/Dashboard.jsx"; import { fetchDatabaseMetadata } from "metabase/redux/metadata"; import { setErrorPage } from "metabase/redux/app"; -import { getIsEditing, getIsEditingParameter, getIsDirty, getDashboardComplete, getCardList, getRevisions, getCardData, getCardDurations, getDatabases, getEditingParameter, getParameterValues } from "../selectors"; +import { getIsEditing, getIsEditingParameter, getIsDirty, getDashboardComplete, getCardList, getRevisions, getCardData, getCardDurations, getEditingParameter, getParameterValues } from "../selectors"; +import { getDatabases } from "metabase/selectors/metadata"; import { getUserIsAdmin } from "metabase/selectors/user"; import * as dashboardActions from "../dashboard"; diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index b486d60e1da69..0a95a33c8e6d3 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -5,8 +5,9 @@ import { updateIn, setIn } from "icepick"; import { createSelector } from 'reselect'; +import { getMeta } from "metabase/selectors/metadata"; + import * as Dashboard from "metabase/meta/Dashboard"; -import Metadata from "metabase/meta/metadata/Metadata"; import type { CardId, Card } from "metabase/meta/types/Card"; import type { ParameterId, DashCardId, ParameterMappingUIOption, Parameter, ParameterMapping } from "metabase/meta/types/Dashboard"; @@ -37,13 +38,6 @@ export const getCardIdList = state => state.dashboard.cardList; export const getRevisions = state => state.dashboard.revisions; export const getParameterValues = state => state.dashboard.parameterValues; -export const getDatabases = state => state.metadata.databases; - -export const getMetadata = createSelector( - [state => state.metadata], - (metadata) => Metadata.fromEntities(metadata) -) - export const getDashboard = createSelector( [getDashboardId, getDashboards], (dashboardId, dashboards) => dashboards[dashboardId] @@ -96,7 +90,7 @@ export const getParameterTarget = createSelector( ); export const getMappingsByParameter = createSelector( - [getMetadata, getDashboardComplete], + [getMeta, getDashboardComplete], (metadata, dashboard) => { if (!dashboard) { return {}; @@ -145,7 +139,7 @@ export const getMappingsByParameter = createSelector( export const makeGetParameterMappingOptions = () => { const getParameterMappingOptions = createSelector( - [getMetadata, getEditingParameter, getCard], + [getMeta, getEditingParameter, getCard], (metadata, parameter: Parameter, card: Card): Array => { return Dashboard.getParameterMappingOptions(metadata, parameter, card); } diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index e8d0360e122e7..1b76eb778b6f5 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -390,7 +390,7 @@ var Query = { // TODO: we need to do something better here because filtering depends on knowing a sensible type for the field base_type: TYPE.Integer, operators_lookup: {}, - valid_operators: [], + operators: [], active: true, fk_target_field_id: null, parent_id: null, @@ -399,8 +399,8 @@ var Query = { target: null, visibility_type: "normal" }; - fieldDef.valid_operators = getOperators(fieldDef, tableDef); - fieldDef.operators_lookup = createLookupByProperty(fieldDef.valid_operators, "name"); + fieldDef.operators = getOperators(fieldDef, tableDef); + fieldDef.operators_lookup = createLookupByProperty(fieldDef.operators, "name"); return { table: tableDef, diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index ef30ddb624d8a..2228bcdd45ee1 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -169,7 +169,7 @@ function equivalentArgument(field, table) { if (isCategory(field)) { if (table.field_values && field.id in table.field_values && table.field_values[field.id].length > 0) { - let validValues = table.field_values[field.id]; + let validValues = [...table.field_values[field.id]]; // this sort function works for both numbers and strings: validValues.sort((a, b) => a === b ? 0 : (a < b ? -1 : 1)); return { @@ -475,7 +475,7 @@ export function getAggregator(short) { return _.findWhere(Aggregators, { short: short }); } -function getBreakouts(fields) { +export function getBreakouts(fields) { var result = populateFields(BreakoutAggregator, fields); result.fields = result.fields[0]; result.validFieldsFilter = result.validFieldsFilters[0]; @@ -484,7 +484,7 @@ function getBreakouts(fields) { export function addValidOperatorsToFields(table) { for (let field of table.fields) { - field.valid_operators = getOperators(field, table); + field.operators = getOperators(field, table); } table.aggregation_options = getAggregatorsWithFields(table); table.breakout_options = getBreakouts(table.fields); diff --git a/frontend/src/metabase/lib/table.js b/frontend/src/metabase/lib/table.js index ce06be09800af..b88b680136c80 100644 --- a/frontend/src/metabase/lib/table.js +++ b/frontend/src/metabase/lib/table.js @@ -35,7 +35,7 @@ export function augmentDatabase(database) { table.fields_lookup = createLookupByProperty(table.fields, "id"); for (let field of table.fields) { addFkTargets(field, database.tables_lookup); - field.operators_lookup = createLookupByProperty(field.valid_operators, "name"); + field.operators_lookup = createLookupByProperty(field.operators, "name"); } } return database; @@ -58,7 +58,7 @@ function populateQueryOptions(table) { _.each(table.fields, function(field) { table.fields_lookup[field.id] = field; field.operators_lookup = {}; - _.each(field.valid_operators, function(operator) { + _.each(field.operators, function(operator) { field.operators_lookup[operator.name] = operator; }); }); diff --git a/frontend/src/metabase/meta/types/Database.js b/frontend/src/metabase/meta/types/Database.js index 719ca45f769fa..2f5685e88898b 100644 --- a/frontend/src/metabase/meta/types/Database.js +++ b/frontend/src/metabase/meta/types/Database.js @@ -1,14 +1,43 @@ /* @flow */ +import type { ISO8601Time } from "."; import type { Table } from "./Table"; export type DatabaseId = number; -// TODO: incomplete +export type DatabaseType = string; // "h2" | "postgres" | etc + +export type DatabaseFeature = + "basic-aggregations" | + "standard-deviation-aggregations"| + "expression-aggregations" | + "foreign-keys" | + "native-parameters" | + "expressions" + +export type DatabaseDetails = { + [key: string]: any +} + +export type DatabaseNativePermission = "write" | "read"; + export type Database = { - id: DatabaseId, + id: DatabaseId, + name: string, + description: ?string, + + tables: Table[], + + details: DatabaseDetails, + engine: DatabaseType, + features: DatabaseFeature[], + is_full_sync: boolean, + is_sample: boolean, + native_permissions: DatabaseNativePermission, - name: string, + caveats: ?string, + points_of_interest: ?string, - tables: Array + created_at: ISO8601Time, + updated_at: ISO8601Time, }; diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 90bbe3f1ed40b..3c3ed80340c49 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -1,5 +1,6 @@ /* @flow */ +import type { ISO8601Time } from "."; import type { FieldId } from "./Field"; import type { DatasetQuery } from "./Card"; @@ -15,8 +16,7 @@ export type Column = { source?: "fields"|"aggregation"|"breakout" }; -export type ISO8601Times = string; -export type Value = string|number|ISO8601Times|boolean|null|{}; +export type Value = string|number|ISO8601Time|boolean|null|{}; export type Row = Value[]; export type DatasetData = { diff --git a/frontend/src/metabase/meta/types/Field.js b/frontend/src/metabase/meta/types/Field.js index b0fe0b67557db..9e0470fc2e3a5 100644 --- a/frontend/src/metabase/meta/types/Field.js +++ b/frontend/src/metabase/meta/types/Field.js @@ -1,10 +1,44 @@ /* @flow */ +import type { ISO8601Time } from "."; +import type { TableId } from "./Table"; + export type FieldId = number; -// TODO: incomplete +export type BaseType = string; +export type SpecialType = string; + +export type FieldVisibilityType = "details-only" | "hidden" | "normal" | "retired"; + export type Field = { - id: FieldId, + id: FieldId, + + name: string, + display_name: string, + description: string, + base_type: BaseType, + special_type: SpecialType, + active: boolean, + visibility_type: FieldVisibilityType, + preview_display: boolean, + position: number, + parent_id: ?FieldId, + + // raw_column_id: number // unused? + + table_id: TableId, + + fk_target_field_id: ?FieldId, + + max_value: ?number, + min_value: ?number, + + caveats: ?string, + points_of_interest: ?string, + + last_analyzed: ISO8601Time, + created_at: ISO8601Time, + updated_at: ISO8601Time, // https://github.com/metabase/metabase/issues/3417 values: Array | { values: Array } diff --git a/frontend/src/metabase/meta/types/Metadata.js b/frontend/src/metabase/meta/types/Metadata.js index 0f81b27617804..724194acdb43e 100644 --- a/frontend/src/metabase/meta/types/Metadata.js +++ b/frontend/src/metabase/meta/types/Metadata.js @@ -2,11 +2,47 @@ // Legacy "tableMetadata" etc -import type { Table } from "metabase/meta/types/Table"; -import type { Field } from "metabase/meta/types/Field"; +import type { Database } from "metabase/meta/types/Database"; +import type { Table, TableId } from "metabase/meta/types/Table"; +import type { Field, FieldId } from "metabase/meta/types/Field"; import type { Segment } from "metabase/meta/types/Segment"; import type { Metric } from "metabase/meta/types/Metric"; +export type DatabaseMetadata = Database & { + tables: TableMetadata[], + tables_lookup: { [id: TableId]: TableMetadata }, +} + +export type TableMetadata = Table & { + db: DatabaseMetadata, + + fields: FieldMetadata[], + fields_lookup: { [id: FieldId]: FieldMetadata }, + + segments: SegmentMetadata[], + metrics: MetricMetadata[], + + aggregation_options: AggregationOption[], + breakout_options: BreakoutOption, +} + +export type FieldMetadata = Field & { + table: TableMetadata, + target: FieldMetadata, + + operators: Operator[], + operators_lookup: { [key: OperatorName]: Operator } +} + +export type SegmentMetadata = Segment & { + table: TableMetadata, +} + +export type MetricMetadata = Metric & { + table: TableMetadata, +} + + export type FieldValue = { name: string, key: string @@ -32,10 +68,6 @@ export type OperatorField = { export type ValidArgumentsFilter = (field: Field, table: Table) => bool; -export type FieldMetadata = Field & { - operators_lookup: { [name: string]: Operator } -} - export type AggregationOption = { name: string, short: string, @@ -43,21 +75,13 @@ export type AggregationOption = { validFieldsFilter: (fields: Field[]) => Field[] } -export type BreakoutOptions = { +export type BreakoutOption = { name: string, short: string, fields: Field[], validFieldsFilter: (fields: Field[]) => Field[] } -export type TableMetadata = Table & { - fields: FieldMetadata[], - segments: Segment[], - metrics: Metric[], - aggregation_options: AggregationOption[], - breakout_options: BreakoutOptions -} - export type FieldOptions = { count: number, fields: Field[], diff --git a/frontend/src/metabase/meta/types/Segment.js b/frontend/src/metabase/meta/types/Segment.js index c928eb47c0e31..1074de8ab6c54 100644 --- a/frontend/src/metabase/meta/types/Segment.js +++ b/frontend/src/metabase/meta/types/Segment.js @@ -1,10 +1,13 @@ /* @flow */ +import type { TableId } from "./Table"; + export type SegmentId = number; // TODO: incomplete export type Segment = { name: string, id: SegmentId, + table_id: TableId, is_active: bool }; diff --git a/frontend/src/metabase/meta/types/Table.js b/frontend/src/metabase/meta/types/Table.js index 51ca40ef2b89d..30e7361a1e972 100644 --- a/frontend/src/metabase/meta/types/Table.js +++ b/frontend/src/metabase/meta/types/Table.js @@ -1,20 +1,51 @@ /* @flow */ -import type { Field } from "./Field"; +import type { ISO8601Time } from "."; + +import type { Field, FieldId } from "./Field"; +import type { Segment } from "./Segment"; +import type { Metric } from "./Metric"; import type { DatabaseId } from "./Database"; export type TableId = number; export type SchemaName = string; +type TableVisibilityType = string; // FIXME + +type FieldValue = any; +type FieldValues = { + [id: FieldId]: FieldValue[] +} + // TODO: incomplete export type Table = { - id: TableId, + id: TableId, + db_id: DatabaseId, + + schema: ?string, + name: string, + display_name: string, + + description: string, + active: boolean, + visibility_type: TableVisibilityType, + + // entity_name: null // unused? + // entity_type: null // unused? + // raw_table_id: number, // unused? + + fields: Field[], + segments: Segment[], + metrics: Metric[], + + field_values: FieldValues, - db_id: DatabaseId, + rows: number, - name: string, - display_name: string, - schema?: SchemaName, + caveats: ?string, + points_of_interest: ?string, + show_in_getting_started: boolean, - fields: Array + updated_at: ISO8601Time, + created_at: ISO8601Time, } diff --git a/frontend/src/metabase/meta/types/index.js b/frontend/src/metabase/meta/types/index.js new file mode 100644 index 0000000000000..114af06b8a46f --- /dev/null +++ b/frontend/src/metabase/meta/types/index.js @@ -0,0 +1,3 @@ +/* @flow */ + +export type ISO8601Time = string; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 990554e953c6c..6d0737eee5cf3 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -307,6 +307,8 @@ export const loadMetadataForCard = createThunkAction(LOAD_METADATA_FOR_CARD, (ca } }); +import { fetchTableMetadata } from "metabase/redux/metadata"; + export const LOAD_TABLE_METADATA = "metabase/qb/LOAD_TABLE_METADATA"; export const loadTableMetadata = createThunkAction(LOAD_TABLE_METADATA, (tableId) => { return async (dispatch, getState) => { @@ -317,7 +319,8 @@ export const loadTableMetadata = createThunkAction(LOAD_TABLE_METADATA, (tableId } try { - return await loadTableAndForeignKeys(tableId); + await dispatch(fetchTableMetadata(tableId)); + // return await loadTableAndForeignKeys(tableId); } catch(error) { console.log('error getting table metadata', error); return {}; diff --git a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx index 8239053f68f3d..e8aca1d206316 100644 --- a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx +++ b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx @@ -86,7 +86,7 @@ export default class FilterPopover extends Component<*, Props, State> { let { field } = Query.getFieldTarget(filter[1], this.props.tableMetadata); // let the DatePicker choose the default operator, otherwise use the first one - let operator = isDate(field) ? null : field.valid_operators[0].name; + let operator = isDate(field) ? null : field.operators[0].name; // $FlowFixMe filter = this._updateOperator(filter, operator); @@ -280,7 +280,7 @@ export default class FilterPopover extends Component<*, Props, State> {
{ this.renderPicker(filter, field) } diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 0a51cc4f03de6..9d04b42c5caec 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -45,6 +45,8 @@ import { getMode, } from "../selectors"; +import { getMetadata } from "metabase/selectors/metadata"; + import { getUserIsAdmin } from "metabase/selectors/user"; import * as actions from "../actions"; @@ -89,6 +91,7 @@ const mapStateToProps = (state, props) => { nativeDatabases: getNativeDatabases(state), tables: getTables(state), tableMetadata: getTableMetadata(state), + metadata: getMetadata(state), tableForeignKeys: getTableForeignKeys(state), tableForeignKeyReferences: getTableForeignKeyReferences(state), diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 9f3472fe8c25a..34b00e45a8e90 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -11,6 +11,8 @@ import { isPK } from "metabase/lib/types"; import Query from "metabase/lib/query"; import Utils from "metabase/lib/utils"; +import { getIn } from "icepick"; + export const getUiControls = state => state.qb.uiControls; export const getCard = state => state.qb.card; @@ -34,6 +36,12 @@ export const getDatabaseId = createSelector( (card) => card && card.dataset_query && card.dataset_query.database ); +export const getTableId = createSelector( + [getCard], + (card) => getIn(card, ["dataset_query", "query", "source_table"]) +); + + export const getDatabases = state => state.qb.databases; export const getTableForeignKeys = state => state.qb.tableForeignKeys; export const getTableForeignKeyReferences = state => state.qb.tableForeignKeyReferences; @@ -58,12 +66,11 @@ export const getNativeDatabases = createSelector( databases && databases.filter(db => db.native_permissions === "write") ) +import { getMetadata } from "metabase/selectors/metadata"; + export const getTableMetadata = createSelector( - [state => state.qb.tableMetadata, getDatabases], - (tableMetadata, databases) => tableMetadata && { - ...tableMetadata, - db: _.findWhere(databases, { id: tableMetadata.db_id }) - } + [getTableId, getMetadata], + (tableId, metadata) => metadata.tables[tableId] ) export const getSampleDatasetId = createSelector( diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 553850a080fa3..33bdfa0e22d25 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -7,22 +7,14 @@ import { updateData, } from "metabase/lib/redux"; -import { normalize, schema } from "normalizr"; +import { normalize } from "normalizr"; +import { DatabaseSchema, TableSchema, FieldSchema, SegmentSchema, MetricSchema } from "metabase/schema"; + import { getIn, assoc, assocIn } from "icepick"; import _ from "underscore"; -import { augmentDatabase, augmentTable } from "metabase/lib/table"; - import { MetabaseApi, MetricApi, SegmentApi, RevisionsApi } from "metabase/services"; -const field = new schema.Entity('fields'); -const table = new schema.Entity('tables', { - fields: [field] -}); -const database = new schema.Entity('databases', { - tables: [table] -}); - const FETCH_METRICS = "metabase/metadata/FETCH_METRICS"; export const fetchMetrics = createThunkAction(FETCH_METRICS, (reload = false) => { return async (dispatch, getState) => { @@ -30,8 +22,7 @@ export const fetchMetrics = createThunkAction(FETCH_METRICS, (reload = false) => const existingStatePath = requestStatePath; const getData = async () => { const metrics = await MetricApi.list(); - const metricMap = resourceListToMap(metrics); - return metricMap; + return normalize(metrics, [MetricSchema]); }; return await fetchData({ @@ -53,12 +44,7 @@ export const updateMetric = createThunkAction(UPDATE_METRIC, function(metric) { const dependentRequestStatePaths = [['metadata', 'revisions', 'metric', metric.id]]; const putData = async () => { const updatedMetric = await MetricApi.update(metric); - const existingMetrics = getIn(getState(), existingStatePath); - const existingMetric = existingMetrics[metric.id]; - - const mergedMetric = {...existingMetric, ...updatedMetric}; - - return assoc(existingMetrics, mergedMetric.id, mergedMetric); + return normalize(updatedMetric, MetricSchema); }; return await updateData({ @@ -93,10 +79,6 @@ export const updateMetricImportantFields = createThunkAction(UPDATE_METRIC_IMPOR }; }); -const metrics = handleActions({ - [FETCH_METRICS]: { next: (state, { payload }) => payload }, - [UPDATE_METRIC]: { next: (state, { payload }) => payload } -}, {}); const FETCH_SEGMENTS = "metabase/metadata/FETCH_SEGMENTS"; export const fetchSegments = createThunkAction(FETCH_SEGMENTS, (reload = false) => { @@ -105,8 +87,7 @@ export const fetchSegments = createThunkAction(FETCH_SEGMENTS, (reload = false) const existingStatePath = requestStatePath; const getData = async () => { const segments = await SegmentApi.list(); - const segmentMap = resourceListToMap(segments); - return segmentMap; + return normalize(segments, [SegmentSchema]); }; return await fetchData({ @@ -128,12 +109,7 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) const dependentRequestStatePaths = [['metadata', 'revisions', 'segment', segment.id]]; const putData = async () => { const updatedSegment = await SegmentApi.update(segment); - const existingSegments = getIn(getState(), existingStatePath); - const existingSegment = existingSegments[segment.id]; - - const mergedSegment = {...existingSegment, ...updatedSegment}; - - return assoc(existingSegments, mergedSegment.id, mergedSegment); + return normalize(updatedSegment, SegmentSchema); }; return await updateData({ @@ -147,11 +123,6 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) }; }); -const segments = handleActions({ - [FETCH_SEGMENTS]: { next: (state, { payload }) => payload }, - [UPDATE_SEGMENT]: { next: (state, { payload }) => payload } -}, {}); - const FETCH_DATABASES = "metabase/metadata/FETCH_DATABASES"; export const fetchDatabases = createThunkAction(FETCH_DATABASES, (reload = false) => { return async (dispatch, getState) => { @@ -159,12 +130,7 @@ export const fetchDatabases = createThunkAction(FETCH_DATABASES, (reload = false const existingStatePath = requestStatePath; const getData = async () => { const databases = await MetabaseApi.db_list(); - const databaseMap = resourceListToMap(databases); - const existingDatabases = getIn(getState(), existingStatePath); - - // to ensure existing databases with fetched metadata doesn't get - // overwritten when loading out of order, unless explicitly reloading - return {...databaseMap, ...existingDatabases}; + return normalize(databases, [DatabaseSchema]); }; return await fetchData({ @@ -185,9 +151,7 @@ export const fetchDatabaseMetadata = createThunkAction(FETCH_DATABASE_METADATA, const existingStatePath = ["metadata"]; const getData = async () => { const databaseMetadata = await MetabaseApi.db_metadata({ dbId }); - await augmentDatabase(databaseMetadata); - - return normalize(databaseMetadata, database).entities; + return normalize(databaseMetadata, DatabaseSchema); }; return await fetchData({ @@ -211,13 +175,7 @@ export const updateDatabase = createThunkAction(UPDATE_DATABASE, function(databa // there may be more that I'm missing? const slimDatabase = _.omit(database, "tables", "tables_lookup"); const updatedDatabase = await MetabaseApi.db_update(slimDatabase); - - const existingDatabases = getIn(getState(), existingStatePath); - const existingDatabase = existingDatabases[database.id]; - - const mergedDatabase = {...existingDatabase, ...updatedDatabase}; - - return assoc(existingDatabases, mergedDatabase.id, mergedDatabase); + return normalize(updatedDatabase, DatabaseSchema); }; return await updateData({ @@ -230,12 +188,6 @@ export const updateDatabase = createThunkAction(UPDATE_DATABASE, function(databa }; }); -const databases = handleActions({ - [FETCH_DATABASES]: { next: (state, { payload }) => payload }, - [FETCH_DATABASE_METADATA]: { next: (state, { payload }) => ({ ...state, ...payload.databases }) }, - [UPDATE_DATABASE]: { next: (state, { payload }) => payload } -}, {}); - const UPDATE_TABLE = "metabase/metadata/UPDATE_TABLE"; export const updateTable = createThunkAction(UPDATE_TABLE, function(table) { return async (dispatch, getState) => { @@ -246,13 +198,7 @@ export const updateTable = createThunkAction(UPDATE_TABLE, function(table) { const slimTable = _.omit(table, "fields", "fields_lookup", "aggregation_options", "breakout_options", "metrics", "segments"); const updatedTable = await MetabaseApi.table_update(slimTable); - - const existingTables = getIn(getState(), existingStatePath); - const existingTable = existingTables[table.id]; - - const mergedTable = {...existingTable, ...updatedTable}; - - return assoc(existingTables, mergedTable.id, mergedTable); + return normalize(updatedTable, TableSchema); }; return await updateData({ @@ -272,11 +218,7 @@ export const fetchTables = createThunkAction(FETCH_TABLES, (reload = false) => { const existingStatePath = requestStatePath; const getData = async () => { const tables = await MetabaseApi.table_list(); - const tableMap = resourceListToMap(tables); - const existingTables = getIn(getState(), existingStatePath); - // to ensure existing tables with fetched metadata doesn't get - // overwritten when loading out of order, unless explicitly reloading - return {...tableMap, ...existingTables}; + return normalize(tables, [TableSchema]); }; return await fetchData({ @@ -297,9 +239,9 @@ export const fetchTableMetadata = createThunkAction(FETCH_TABLE_METADATA, functi const existingStatePath = ["metadata"]; const getData = async () => { const tableMetadata = await MetabaseApi.table_query_metadata({ tableId }); - await augmentTable(tableMetadata); - - return normalize(tableMetadata, table).entities; + const fkTableIds = _.chain(tableMetadata.fields).filter(field => field.target).map(field => field.target.table_id).uniq().value(); + const fkTables = await Promise.all(fkTableIds.map(tableId => MetabaseApi.table_query_metadata({ tableId }))); + return normalize([tableMetadata].concat(fkTables), [TableSchema]); }; return await fetchData({ @@ -313,13 +255,6 @@ export const fetchTableMetadata = createThunkAction(FETCH_TABLE_METADATA, functi }; }); -const tables = handleActions({ - [UPDATE_TABLE]: { next: (state, { payload }) => payload }, - [FETCH_TABLES]: { next: (state, { payload }) => payload }, - [FETCH_TABLE_METADATA]: { next: (state, { payload }) => ({ ...state, ...payload.tables }) }, - [FETCH_DATABASE_METADATA]: { next: (state, { payload }) => ({ ...state, ...payload.tables }) } -}, {}); - const UPDATE_FIELD = "metabase/metadata/UPDATE_FIELD"; export const updateField = createThunkAction(UPDATE_FIELD, function(field) { return async function(dispatch, getState) { @@ -330,13 +265,8 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { // there may be more that I'm missing? const slimField = _.omit(field, "operators_lookup"); - const fieldMetadata = await MetabaseApi.field_update(slimField); - const existingFields = getIn(getState(), existingStatePath); - const existingField = existingFields[field.id]; - - const mergedField = {...existingField, ...fieldMetadata}; - - return assoc(existingFields, mergedField.id, mergedField); + const updatedField = await MetabaseApi.field_update(slimField); + return normalize(updatedField, FieldSchema); }; return await updateData({ @@ -349,25 +279,6 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { }; }); -const fields = handleActions({ - [FETCH_TABLE_METADATA]: { next: (state, { payload }) => ({ ...state, ...payload.fields }) }, - [FETCH_DATABASE_METADATA]: { next: (state, { payload }) => ({ ...state, ...payload.fields }) }, - [UPDATE_FIELD]: { next: (state, { payload }) => payload }, - // NOTE: from metabase/dashboard/dashboard - ["metabase/dashboard/FETCH_DASHBOARD"]: { next: (state, { payload }) => { - // extract field values from dashboard endpoint - if (payload.entities && payload.entities.dashboard) { - for (const dashboard of Object.values(payload.entities.dashboard)) { - if (dashboard.param_values) { - for (const fieldValues of Object.values(dashboard.param_values)) { - state = assocIn(state, [fieldValues.field_id, "values"], fieldValues); - } - } - } - } - return state; - }} -}, {}); const FETCH_REVISIONS = "metabase/metadata/FETCH_REVISIONS"; export const fetchRevisions = createThunkAction(FETCH_REVISIONS, (type, id, reload = false) => { @@ -393,15 +304,11 @@ export const fetchRevisions = createThunkAction(FETCH_REVISIONS, (type, id, relo }; }); -const revisions = handleActions({ - [FETCH_REVISIONS]: { next: (state, { payload }) => payload } -}, {}); - // for fetches with data dependencies in /reference const FETCH_METRIC_TABLE = "metabase/metadata/FETCH_METRIC_TABLE"; export const fetchMetricTable = createThunkAction(FETCH_METRIC_TABLE, (metricId, reload = false) => { return async (dispatch, getState) => { - await dispatch(fetchMetrics()); + await dispatch(fetchMetrics()); // FIXME: fetchMetric? const metric = getIn(getState(), ['metadata', 'metrics', metricId]); const tableId = metric.table_id; await dispatch(fetchTableMetadata(tableId)); @@ -424,7 +331,7 @@ export const fetchMetricRevisions = createThunkAction(FETCH_METRIC_REVISIONS, (m const FETCH_SEGMENT_FIELDS = "metabase/metadata/FETCH_SEGMENT_FIELDS"; export const fetchSegmentFields = createThunkAction(FETCH_SEGMENT_FIELDS, (segmentId, reload = false) => { return async (dispatch, getState) => { - await dispatch(fetchSegments()); + await dispatch(fetchSegments()); // FIXME: fetchSegment? const segment = getIn(getState(), ['metadata', 'segments', segmentId]); const tableId = segment.table_id; await dispatch(fetchTableMetadata(tableId)); @@ -437,7 +344,7 @@ export const fetchSegmentFields = createThunkAction(FETCH_SEGMENT_FIELDS, (segme const FETCH_SEGMENT_TABLE = "metabase/metadata/FETCH_SEGMENT_TABLE"; export const fetchSegmentTable = createThunkAction(FETCH_SEGMENT_TABLE, (segmentId, reload = false) => { return async (dispatch, getState) => { - await dispatch(fetchSegments()); + await dispatch(fetchSegments()); // FIXME: fetchSegment? const segment = getIn(getState(), ['metadata', 'segments', segmentId]); const tableId = segment.table_id; await dispatch(fetchTableMetadata(tableId)); @@ -462,18 +369,75 @@ export const fetchDatabasesWithMetadata = createThunkAction(FETCH_DATABASES_WITH return async (dispatch, getState) => { await dispatch(fetchDatabases()); const databases = getIn(getState(), ['metadata', 'databases']); - await Promise.all( - Object.keys(databases) - .map(databaseId => dispatch(fetchDatabaseMetadata(databaseId))) - ); + await Promise.all(Object.values(databases).map(database => + dispatch(fetchDatabaseMetadata(database.id)) + )); }; }); +const databases = handleActions({ +}, {}); + +const tables = handleActions({ +}, {}); + +const fields = handleActions({ + // NOTE: from metabase/dashboard/dashboard + ["metabase/dashboard/FETCH_DASHBOARD"]: { next: (state, { payload }) => { + // extract field values from dashboard endpoint + if (payload.entities && payload.entities.dashboard) { + for (const dashboard of Object.values(payload.entities.dashboard)) { + if (dashboard.param_values) { + for (const fieldValues of Object.values(dashboard.param_values)) { + state = assocIn(state, [fieldValues.field_id, "values"], fieldValues); + } + } + } + } + return state; + }} +}, {}); + +const metrics = handleActions({ +}, {}); + +const segments = handleActions({ +}, {}); + +const revisions = handleActions({ + [FETCH_REVISIONS]: { next: (state, { payload }) => payload } +}, {}); + +function mergeEntities(entities, newEntities) { + entities = { ...entities }; + for (const id in newEntities) { + if (id in entities) { + entities[id] = { ...entities[id], ...newEntities[id] }; + } else { + entities[id] = newEntities[id]; + } + } + return entities; +} + +function handleEntities(actionPattern, entityType, reducer) { + return (state, action) => { + if (state === undefined) { + state = {}; + } + let entities = getIn(action, ["payload", "entities", entityType]) + if (actionPattern.test(action.type) && entities) { + state = mergeEntities(state, entities); + } + return reducer(state, action); + } +} + export default combineReducers({ - metrics, - segments, - databases, - tables, - fields, + metrics: handleEntities(/^metabase\/metadata\//, "metrics", metrics), + segments: handleEntities(/^metabase\/metadata\//, "segments", segments), + databases: handleEntities(/^metabase\/metadata\//, "databases", databases), + tables: handleEntities(/^metabase\/metadata\//, "tables", tables), + fields: handleEntities(/^metabase\/metadata\//, "fields", fields), revisions }); diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx index c5218c5073f1a..346b08be2b47f 100644 --- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx @@ -5,7 +5,6 @@ import { Link } from "react-router"; import { connect } from 'react-redux'; import { reduxForm } from "redux-form"; -import { assoc } from "icepick"; import cx from "classnames"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; @@ -33,15 +32,15 @@ import { getGuide, getUser, getDashboards, - getMetrics, - getSegments, - getTables, - getFields, - getDatabases, getLoading, getError, getIsEditing, - getIsDashboardModalOpen + getIsDashboardModalOpen, + getDatabases, + getTables, + getFields, + getMetrics, + getSegments, } from '../selectors'; import { @@ -71,15 +70,18 @@ const mapStateToProps = (state, props) => { {}, important_metrics: guide.important_metrics && guide.important_metrics.length > 0 ? guide.important_metrics - .map(metricId => metrics[metricId] && assoc(metrics[metricId], 'important_fields', guide.metric_important_fields[metricId] && guide.metric_important_fields[metricId].map(fieldId => fields[fieldId]))) : + .map(metricId => metrics[metricId] && { + ...metrics[metricId], + important_fields: guide.metric_important_fields[metricId] && guide.metric_important_fields[metricId].map(fieldId => fields[fieldId]) + }) : [], important_segments_and_tables: (guide.important_segments && guide.important_segments.length > 0) || (guide.important_tables && guide.important_tables.length > 0) ? guide.important_segments - .map(segmentId => segments[segmentId] && assoc(segments[segmentId], 'type', 'segment')) + .map(segmentId => segments[segmentId] && { ...segments[segmentId], type: 'segment' }) .concat(guide.important_tables - .map(tableId => tables[tableId] && assoc(tables[tableId], 'type', 'table')) + .map(tableId => tables[tableId] && { ...tables[tableId], type: 'table' }) ) : [] }; diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js index af8d94b3041d5..f9f1a17b2f940 100644 --- a/frontend/src/metabase/reference/selectors.js +++ b/frontend/src/metabase/reference/selectors.js @@ -14,6 +14,11 @@ import { getQuestionUrl } from "./utils"; +// import { getDatabases, getTables, getFields, getMetrics, getSegments } from "metabase/selectors/metadata"; + +import { getShallowDatabases as getDatabases, getShallowTables as getTables, getShallowFields as getFields, getShallowMetrics as getMetrics, getShallowSegments as getSegments } from "metabase/selectors/metadata"; +export { getShallowDatabases as getDatabases, getShallowTables as getTables, getShallowFields as getFields, getShallowMetrics as getMetrics, getShallowSegments as getSegments } from "metabase/selectors/metadata"; + import _ from "underscore"; @@ -140,7 +145,10 @@ const getMetricSections = (metric, table, user) => metric ? { type: 'questions', sidebar: 'Questions about this metric', breadcrumb: `${metric.name}`, - fetch: {fetchMetricTable: [metric.id], fetchQuestions: []}, + fetch: { + fetchMetricTable: [metric.id], + fetchQuestions: [] + }, get: 'getMetricQuestions', icon: "all", headerIcon: "ruler", @@ -152,7 +160,9 @@ const getMetricSections = (metric, table, user) => metric ? { sidebar: 'Revision history', breadcrumb: `${metric.name}`, hidden: user && !user.is_superuser, - fetch: {fetchMetricRevisions: [metric.id]}, + fetch: { + fetchMetricRevisions: [metric.id] + }, get: 'getMetricRevisions', icon: "history", headerIcon: "ruler", @@ -188,7 +198,9 @@ const getSegmentSections = (segment, table, user) => segment ? { } ], breadcrumb: `${segment.name}`, - fetch: {fetchSegmentTable: [segment.id]}, + fetch: { + fetchSegmentTable: [segment.id] + }, get: 'getSegment', icon: "document", headerIcon: "segment", @@ -207,7 +219,9 @@ const getSegmentSections = (segment, table, user) => segment ? { icon: "fields" }, sidebar: 'Fields in this segment', - fetch: {fetchSegmentFields: [segment.id]}, + fetch: { + fetchSegmentFields: [segment.id] + }, get: "getFieldsBySegment", breadcrumb: `${segment.name}`, icon: "fields", @@ -230,7 +244,10 @@ const getSegmentSections = (segment, table, user) => segment ? { type: 'questions', sidebar: 'Questions about this segment', breadcrumb: `${segment.name}`, - fetch: {fetchSegmentTable: [segment.id], fetchQuestions: []}, + fetch: { + fetchSegmentTable: [segment.id], + fetchQuestions: [] + }, get: 'getSegmentQuestions', icon: "all", headerIcon: "segment", @@ -242,7 +259,9 @@ const getSegmentSections = (segment, table, user) => segment ? { sidebar: 'Revision history', breadcrumb: `${segment.name}`, hidden: user && !user.is_superuser, - fetch: {fetchSegmentRevisions: [segment.id]}, + fetch: { + fetchSegmentRevisions: [segment.id] + }, get: 'getSegmentRevisions', icon: "history", headerIcon: "segment", @@ -278,7 +297,9 @@ const getSegmentFieldSections = (segment, table, field, user) => segment && fiel } ], breadcrumb: `${field.display_name}`, - fetch: {fetchSegmentFields: [segment.id]}, + fetch: { + fetchSegmentFields: [segment.id] + }, get: "getFieldBySegment", icon: "document", headerIcon: "field", @@ -293,7 +314,9 @@ const getDatabaseSections = (database) => database ? { update: 'updateDatabase', type: 'database', breadcrumb: `${database.name}`, - fetch: {fetchDatabaseMetadata: [database.id]}, + fetch: { + fetchDatabaseMetadata: [database.id] + }, get: 'getDatabase', icon: "document", headerIcon: "database", @@ -309,7 +332,9 @@ const getDatabaseSections = (database) => database ? { }, sidebar: 'Tables in this database', breadcrumb: `${database.name}`, - fetch: {fetchDatabaseMetadata: [database.id]}, + fetch: { + fetchDatabaseMetadata: [database.id] + }, get: 'getTablesByDatabase', icon: "table2", headerIcon: "database", @@ -343,7 +368,9 @@ const getTableSections = (database, table) => database && table ? { } ], breadcrumb: `${table.display_name}`, - fetch: {fetchDatabaseMetadata: [database.id]}, + fetch: { + fetchDatabaseMetadata: [database.id] + }, get: 'getTable', icon: "document", headerIcon: "table2", @@ -362,7 +389,9 @@ const getTableSections = (database, table) => database && table ? { }, sidebar: 'Fields in this table', breadcrumb: `${table.display_name}`, - fetch: {fetchDatabaseMetadata: [database.id]}, + fetch: { + fetchDatabaseMetadata: [database.id] + }, get: "getFieldsByTable", icon: "fields", headerIcon: "table2", @@ -383,7 +412,9 @@ const getTableSections = (database, table) => database && table ? { type: 'questions', sidebar: 'Questions about this table', breadcrumb: `${table.display_name}`, - fetch: {fetchDatabaseMetadata: [database.id], fetchQuestions: []}, + fetch: { + fetchDatabaseMetadata: [database.id], fetchQuestions: [] + }, get: 'getTableQuestions', icon: "all", headerIcon: "table2", @@ -431,7 +462,9 @@ const getTableFieldSections = (database, table, field) => database && table && f } ], breadcrumb: `${field.display_name}`, - fetch: {fetchDatabaseMetadata: [database.id]}, + fetch: { + fetchDatabaseMetadata: [database.id] + }, get: "getField", icon: "document", headerIcon: "field", @@ -444,21 +477,19 @@ export const getUser = (state, props) => state.currentUser; export const getSectionId = (state, props) => props.location.pathname; export const getMetricId = (state, props) => Number.parseInt(props.params.metricId); -export const getMetrics = (state, props) => state.metadata.metrics; export const getMetric = createSelector( [getMetricId, getMetrics], (metricId, metrics) => metrics[metricId] || { id: metricId } ); export const getSegmentId = (state, props) => Number.parseInt(props.params.segmentId); -export const getSegments = (state, props) => state.metadata.segments; export const getSegment = createSelector( [getSegmentId, getSegments], (segmentId, segments) => segments[segmentId] || { id: segmentId } ); export const getDatabaseId = (state, props) => Number.parseInt(props.params.databaseId); -export const getDatabases = (state, props) => state.metadata.databases; + const getDatabase = createSelector( [getDatabaseId, getDatabases], (databaseId, databases) => databases[databaseId] || { id: databaseId } @@ -466,7 +497,6 @@ const getDatabase = createSelector( export const getTableId = (state, props) => Number.parseInt(props.params.tableId); // export const getTableId = (state, props) => Number.parseInt(props.params.tableId); -export const getTables = (state, props) => state.metadata.tables; const getTablesByDatabase = createSelector( [getTables, getDatabase], (tables, database) => tables && database && database.tables ? @@ -489,7 +519,6 @@ export const getTable = createSelector( ); export const getFieldId = (state, props) => Number.parseInt(props.params.fieldId); -export const getFields = (state, props) => state.metadata.fields; const getFieldsByTable = createSelector( [getTable, getFields], (table, fields) => table && table.fields ? idsToObjectMap(table.fields, fields) : {} diff --git a/frontend/src/metabase/schema.js b/frontend/src/metabase/schema.js new file mode 100644 index 0000000000000..70803fc5eea52 --- /dev/null +++ b/frontend/src/metabase/schema.js @@ -0,0 +1,34 @@ + +// normalizr schema for use in actions/reducers + +import { schema } from "normalizr"; + +export const DatabaseSchema = new schema.Entity('databases'); +export const TableSchema = new schema.Entity('tables'); +export const FieldSchema = new schema.Entity('fields'); +export const SegmentSchema = new schema.Entity('segments'); +export const MetricSchema = new schema.Entity('metrics'); + +DatabaseSchema.define({ + tables: [TableSchema] +}); + +TableSchema.define({ + db: DatabaseSchema, + fields: [FieldSchema], + segments: [SegmentSchema], + metrics: [MetricSchema] +}); + +FieldSchema.define({ + target: FieldSchema, + table: TableSchema, +}); + +SegmentSchema.define({ + table: TableSchema, +}); + +MetricSchema.define({ + table: TableSchema, +}); diff --git a/frontend/src/metabase/selectors/metadata.js b/frontend/src/metabase/selectors/metadata.js index 744a84b3c609f..2c634e4eb2cb5 100644 --- a/frontend/src/metabase/selectors/metadata.js +++ b/frontend/src/metabase/selectors/metadata.js @@ -1,5 +1,151 @@ +/* @flow weak */ -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); +import { createSelector } from "reselect"; + +import Metadata from "metabase/meta/metadata/Metadata"; + +import { + getOperators, + getBreakouts, + getAggregatorsWithFields +} from "metabase/lib/schema_metadata"; + +export const getNormalizedMetadata = state => state.metadata; + +export const getMeta = createSelector([getNormalizedMetadata], metadata => + Metadata.fromEntities(metadata)); + +// fully denomalized, raw "entities" +export const getNormalizedDatabases = state => state.metadata.databases; +export const getNormalizedTables = state => state.metadata.tables; +export const getNormalizedFields = state => state.metadata.fields; +export const getNormalizedMetrics = state => state.metadata.metrics; +export const getNormalizedSegments = state => state.metadata.segments; + + +// TODO: these should be denomalized but non-cylical, and only to the same "depth" previous "tableMetadata" was, e.x. +// +// TABLE: +// +// { +// db: { +// tables: undefined, +// } +// fields: [{ +// table: undefined, +// target: { +// table: { +// fields: undefined +// } +// } +// }] +// } +// +export const getShallowDatabases = getNormalizedDatabases; +export const getShallowTables = getNormalizedTables; +export const getShallowFields = getNormalizedFields; +export const getShallowMetrics = getNormalizedMetrics; +export const getShallowSegments = getNormalizedSegments; + +// fully connected graph of all databases, tables, fields, segments, and metrics +export const getMetadata = createSelector( + [ + getNormalizedDatabases, + getNormalizedTables, + getNormalizedFields, + getNormalizedSegments, + getNormalizedMetrics + ], + (databases, tables, fields, segments, metrics) => { + const meta = { + databases: copyObjects(databases), + tables: copyObjects(tables), + fields: copyObjects(fields), + segments: copyObjects(segments), + metrics: copyObjects(metrics) + }; + + hydrateList(meta.databases, "tables", meta.tables); + + hydrateList(meta.tables, "fields", meta.fields); + hydrateList(meta.tables, "segments", meta.segments); + hydrateList(meta.tables, "metrics", meta.metrics); + + hydrate(meta.tables, "db", t => meta.databases[t.db_id]); + + hydrate(meta.segments, "table", s => meta.tables[s.table_id]); + hydrate(meta.metrics, "table", m => meta.tables[m.table_id]); + hydrate(meta.fields, "table", f => meta.tables[f.table_id]); + + hydrate(meta.fields, "target", f => meta.fields[f.fk_target_field_id]); + + hydrate(meta.fields, "operators", f => getOperators(f, f.table)); + hydrate(meta.tables, "aggregation_options", t => + getAggregatorsWithFields(t)); + hydrate(meta.tables, "breakout_options", t => getBreakouts(t.fields)); + + hydrateLookup(meta.databases, "tables", "id"); + hydrateLookup(meta.tables, "fields", "id"); + hydrateLookup(meta.fields, "operators", "name"); + + return meta; + } +); + +export const getDatabases = createSelector( + [getMetadata], + ({ databases }) => databases +); + +export const getTables = createSelector([getMetadata], ({ tables }) => tables); + +export const getFields = createSelector([getMetadata], ({ fields }) => fields); +export const getMetrics = createSelector( + [getMetadata], + ({ metrics }) => metrics +); + +export const getSegments = createSelector( + [getMetadata], + ({ segments }) => segments +); + + +// UTILS: + +// clone each object in the provided mapping of objects +function copyObjects(objects) { + let copies = {}; + for (const object of Object.values(objects)) { + copies[object.id] = { ...object }; + } + return copies; +} + +// calls a function to derive the value of a property for every object +function hydrate(objects, property, getPropertyValue) { + for (const object of Object.values(objects)) { + object[property] = getPropertyValue(object); + } +} + +// replaces lists of ids with the actual objects +function hydrateList(objects, property, targetObjects) { + hydrate( + objects, + property, + object => + object[property] && object[property].map(id => targetObjects[id]) + ); +} + +// creates a *_lookup object for a previously hydrated list +function hydrateLookup(objects, property, idProperty = "id") { + hydrate(objects, property + "_lookup", object => { + let lookup = {}; + for (const item of object[property] || []) { + lookup[item[idProperty]] = item; + } + return lookup; + }); +} From c46c18f77ed91a645f03e0940e67d92b8523208f Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Fri, 21 Apr 2017 01:49:32 -0700 Subject: [PATCH 006/202] Right click prototype (only on scalar and table for now) --- .../components/TableInteractive.jsx | 17 ++++++++++------ .../components/Visualization.jsx | 20 +++++++++++++------ .../visualizations/visualizations/Scalar.jsx | 4 +++- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index af8a77121c4f6..17868a4b61e4b 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -245,6 +245,9 @@ export default class TableInteractive extends Component<*, Props, State> { } const isClickable = onVisualizationClick && visualizationIsClickable(clicked); + const onClick = isClickable && ((e) => { + onVisualizationClick({ ...clicked, element: e.currentTarget, e: e.nativeEvent }); + }) return (
{ "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, "cursor-pointer": isClickable })} - onClick={isClickable && ((e) => { - onVisualizationClick({ ...clicked, element: e.currentTarget }); - })} + onClick={onClick} + onContextMenu={onClick} >
@@ -287,6 +289,10 @@ export default class TableInteractive extends Component<*, Props, State> { } const isClickable = onVisualizationClick && visualizationIsClickable(clicked); + const onClick = isClickable && ((e) => { + onVisualizationClick({ ...clicked, element: e.currentTarget, e: e.nativeEvent }); + }) + const isSortable = isClickable && column.source; return ( @@ -300,9 +306,8 @@ export default class TableInteractive extends Component<*, Props, State> { >
{ - onVisualizationClick({ ...clicked, element: e.currentTarget }); - })} + onClick={onClick} + onContextMenu={onClick} > {columnTitle} {isSortable && diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index bfc935a93c8a4..68cb7feddc065 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -204,14 +204,22 @@ export default class Visualization extends Component<*, Props, State> { } handleVisualizationClick = (clicked: ClickObject) => { + const { onChangeCardAndRun } = this.props; + + const clickActions = this.getClickActions(clicked); + const defaultClickActions = clickActions.filter(a => a.default); + + const wasRightClick = clicked.e && clicked.e.type === "contextmenu"; + + if (wasRightClick && clickActions.length > 0) { + clicked.e.preventDefault(); + } + // 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 { + if (!wasRightClick && defaultClickActions.length === 1 && defaultClickActions[0].card) { + onChangeCardAndRun(defaultClickActions[0].card()); + } else if (clickActions.length > 0) { this.setState({ clicked }); } }, 100) diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index f55135518c34f..4662e69b4ccb0 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -173,6 +173,7 @@ export default class Scalar extends Component<*, VisualizationProps, *> { column: cols[0] }; const isClickable = visualizationIsClickable(clicked); + const onClick = isClickable && ((e) => this._scalar && onVisualizationClick({ ...clicked, element: this._scalar, e: e.nativeEvent })) return (
@@ -186,7 +187,8 @@ export default class Scalar extends Component<*, VisualizationProps, *> { style={{maxWidth: '100%'}} > this._scalar && onVisualizationClick({ ...clicked, element: this._scalar }))} + onClick={onClick} + onContextMenu={onClick} ref={scalar => this._scalar = scalar} > {compactScalarValue} From fa4baacd8351a2ebf262b4f621f840ff5216c1a2 Mon Sep 17 00:00:00 2001 From: Stefano Dissegna Date: Sat, 22 Apr 2017 20:47:05 +0200 Subject: [PATCH 007/202] added excel export format --- .../components/QueryDownloadWidget.jsx | 6 +++-- project.clj | 3 ++- src/metabase/api/card.clj | 10 +++++++ src/metabase/api/dataset.clj | 27 +++++++++++++++++++ src/metabase/api/embed.clj | 4 +++ src/metabase/api/public.clj | 6 +++++ src/metabase/models/query_execution.clj | 1 + src/metabase/routes.clj | 2 ++ 8 files changed, 56 insertions(+), 3 deletions(-) diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index 0726bbdc7a7ab..2f9a29423a9c4 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -13,6 +13,8 @@ import * as Urls from "metabase/lib/urls"; import _ from "underscore"; import cx from "classnames"; +const EXPORT_FORMATS = ["csv", "xlsx", "json"]; + const QueryDownloadWidget = ({ className, card, result, uuid, token }) => } triggerClasses={cx(className, "text-brand-hover")} > -
+

Download

{ result.data.rows_truncated != null &&
@@ -31,7 +33,7 @@ const QueryDownloadWidget = ({ className, card, result, uuid, token }) =>
}
- {["csv", "json"].map(type => + {EXPORT_FORMATS.map(type => uuid ? : token ? diff --git a/project.clj b/project.clj index f972daf60dc01..2d64098d31471 100644 --- a/project.clj +++ b/project.clj @@ -78,7 +78,8 @@ [ring/ring-json "0.4.0"] ; Ring middleware for reading/writing JSON automatically [stencil "0.5.0"] ; Mustache templates for Clojure [toucan "1.0.2" ; Model layer, hydration, and DB utilities - :exclusions [honeysql]]] + :exclusions [honeysql]] + [dk.ative/docjure "1.11.0"]] ; Excel export :repositories [["bintray" "https://dl.bintray.com/crate/crate"]] ; Repo for Crate JDBC driver :plugins [[lein-environ "1.1.0"] ; easy access to environment variables [lein-ring "0.11.0" ; start the HTTP server with 'lein ring server' diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index e27c8c855ce6d..3cc116870ab1d 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -427,6 +427,16 @@ :constraints nil :context :csv-download)))) +(defendpoint POST "/:card-id/query/xlsx" + "Run the query associated with a Card, and return its results as XLSX. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" + [card-id parameters] + {parameters (s/maybe su/JSONString)} + (binding [cache/*ignore-cached-results* true] + (dataset-api/as-xlsx (run-query-for-card card-id + :parameters (json/parse-string parameters keyword) + :constraints nil + :context :xlsx-download)))) + (defendpoint POST "/:card-id/query/json" "Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" [card-id parameters] diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 0dae851c67f8b..ed0e8e15ffbba 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -3,6 +3,7 @@ (:require [clojure.data.csv :as csv] [cheshire.core :as json] [compojure.core :refer [GET POST]] + [dk.ative.docjure.spreadsheet :as spreadsheet] [metabase.api.common :refer :all] (toucan [db :as db] [hydrate :refer [hydrate]]) @@ -62,6 +63,24 @@ {:status 500 :body (:error response)})) +(defn as-xlsx + "Return an XLSX response containing the RESULTS of a query." + {:arglists '([results])} + [{{:keys [columns rows]} :data, :keys [status], :as response}] + (if (= status :completed) + ;; successful query, send XLSX file + {:status 200 + :body (let [wb (spreadsheet/create-workbook "Query result" (conj rows (mapv name columns))) + ; note: byte array streams don't need to be closed + out (java.io.ByteArrayOutputStream.)] + (spreadsheet/save-workbook! out wb) + (java.io.ByteArrayInputStream. (.toByteArray out))) + :headers {"Content-Type" "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" + "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".xlsx\"")}} + ;; failed query, send error message + {:status 500 + :body (:error response)})) + (defn as-json "Return a JSON response containing the RESULTS of a query." {:arglists '([results])} @@ -84,6 +103,14 @@ (read-check Database (:database query)) (as-csv (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :csv-download})))) +(defendpoint POST "/xlsx" + "Execute a query and download the result data as an XLSX file." + [query] + {query su/JSONString} + (let [query (json/parse-string query keyword)] + (read-check Database (:database query)) + (as-xlsx (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :xlsx-download})))) + (defendpoint POST "/json" "Execute a query and download the result data as a JSON file." [query] diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index 5f713e6212dad..b00ab4c396a84 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -279,6 +279,10 @@ [token & query-params] (dataset-api/as-csv (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) +(api/defendpoint GET "/card/:token/query/xlsx" + "Like `GET /api/embed/card/query`, but returns the results as XLSX." + [token & query-params] + (dataset-api/as-xlsx (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) (api/defendpoint GET "/card/:token/query/json" "Like `GET /api/embed/card/query`, but returns the results as JSOn." diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index 710115f56295c..dd3a953ea1bec 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -87,6 +87,12 @@ {parameters (s/maybe su/JSONString)} (dataset-api/as-csv (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) +(api/defendpoint GET "/card/:uuid/query/xlsx" + "Fetch a publically-accessible Card and return query results as XLSX. Does not require auth credentials. Public sharing must be enabled." + [uuid parameters] + {parameters (s/maybe su/JSONString)} + (dataset-api/as-xlsx (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) + ;;; ------------------------------------------------------------ Public Dashboards ------------------------------------------------------------ diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj index 83e7ea1597cd4..1dd4a88b9e63d 100644 --- a/src/metabase/models/query_execution.clj +++ b/src/metabase/models/query_execution.clj @@ -16,6 +16,7 @@ "Schema for valid values of QueryExecution `:context`." (s/enum :ad-hoc :csv-download + :xlsx-download :dashboard :embedded-dashboard :embedded-question diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index f33dfdbed2035..7f59454fd3302 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -34,11 +34,13 @@ (defroutes ^:private public-routes (GET ["/question/:uuid.csv" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/csv" uuid))) + (GET ["/question/:uuid.xlsx" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/xlsx" uuid))) (GET ["/question/:uuid.json" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/json" uuid))) (GET "*" [] public)) (defroutes ^:private embed-routes (GET "/question/:token.csv" [token] (resp/redirect (format "/api/embed/card/%s/query/csv" token))) + (GET "/question/:token.xlsx" [token] (resp/redirect (format "/api/embed/card/%s/query/xlsx" token))) (GET "/question/:token.json" [token] (resp/redirect (format "/api/embed/card/%s/query/json" token))) (GET "*" [] embed)) From facb0b6e06e83763c464c8a1d84a7a900a074bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Sun, 23 Apr 2017 18:07:34 -0700 Subject: [PATCH 008/202] Suppress React warning by adding keys to permissions edit bar buttons --- .../admin/permissions/components/PermissionsEditor.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx index a35dcf3310070..04de3ec712f7b 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx @@ -20,6 +20,7 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate action={onSave} content={} triggerClasses={cx({ disabled: !isDirty })} + key="save" > ; @@ -29,11 +30,12 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate title="Discard changes?" action={onCancel} content="No changes to permissions will be made." + key="discard" > : - ; + ; return ( From cd9d064e87907c61b47c49cfc346128d99ac754e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Mon, 24 Apr 2017 09:08:17 -0700 Subject: [PATCH 009/202] Show warning if the user tries to remove access to the last table for a user group --- .../metabase/admin/permissions/selectors.js | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index efaad73d99df7..cd82ce2e55924 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -135,6 +135,37 @@ function getRawQueryWarningModal(permissions, groupId, entityId, value) { } } +// If the user is revoking an access to every single table of a database for a specific user group, +// warn the user that the access to raw queries will be revoked as well. +// This warning will only be shown if the user is editing the permissions of individual tables. +function getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value) { + if (value === "none" && + getSchemasPermission(permissions, groupId, entityId) === "controlled" && + getNativePermission(permissions, groupId, entityId) !== "none" + ) { + // Contains tables from all schemas, making sure that the warning is shown only + // if user tries to revoke access to the very last table of all schemas + const allTableEntityIds = database.tables().map((table) => ({ + databaseId: table.db_id, + schemaName: table.schema, + tableId: table.id + })) + + const afterChangesNoAccessToAnyTable = _.every(allTableEntityIds, (id) => + getFieldsPermission(permissions, groupId, id) === "none" || _.isEqual(id, entityId) + ) + + if (afterChangesNoAccessToAnyTable) { + return { + title: "Revoke access to all tables?", + message: "This will also revoke this group's access to raw queries for this database.", + confirmButtonText: "Revoke access", + cancelButtonText: "Cancel" + }; + } + } +} + const OPTION_GREEN = { icon: "check", iconColor: "#9CC177", @@ -242,7 +273,8 @@ export const getTablesPermissionsGrid = createSelector( confirm(groupId, entityId, value) { return [ getPermissionWarningModal(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId, value), - getControlledDatabaseWarningModal(permissions, groupId, entityId) + getControlledDatabaseWarningModal(permissions, groupId, entityId), + getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value) ]; }, warning(groupId, entityId) { @@ -364,7 +396,7 @@ export const getDatabasesPermissionsGrid = createSelector( }, confirm(groupId, entityId, value) { return [ - getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value) + getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value), ]; }, warning(groupId, entityId) { From 679a4e86b5d08e8170a950c4d724185ac430de49 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Mon, 24 Apr 2017 10:33:56 -0700 Subject: [PATCH 010/202] Misc polish --- .../src/metabase/visualizations/components/LeafletMap.jsx | 4 +++- frontend/src/metabase/visualizations/components/PinMap.jsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/metabase/visualizations/components/LeafletMap.jsx b/frontend/src/metabase/visualizations/components/LeafletMap.jsx index 64a3e8e1bca3d..065cc15d6c96e 100644 --- a/frontend/src/metabase/visualizations/components/LeafletMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletMap.jsx @@ -20,7 +20,8 @@ export default class LeafletMap extends Component { const map = this.map = L.map(element, { scrollWheelZoom: false, - minZoom: 2 + minZoom: 2, + drawControlTooltips: false }); const drawnItems = new L.FeatureGroup(); @@ -73,6 +74,7 @@ export default class LeafletMap extends Component { ], settings["map.zoom"]); } else { this.map.fitBounds(bounds); + this.map.setZoom(this.map.getBoundsZoom(bounds, true)); } } } diff --git a/frontend/src/metabase/visualizations/components/PinMap.jsx b/frontend/src/metabase/visualizations/components/PinMap.jsx index 5f6a7fff199d3..4ac01ee845ed6 100644 --- a/frontend/src/metabase/visualizations/components/PinMap.jsx +++ b/frontend/src/metabase/visualizations/components/PinMap.jsx @@ -106,7 +106,7 @@ export default class PinMap extends Component<*, Props, State> { const { points, bounds } = this.state;//this._getPoints(this.props); return ( -
e.stopPropagation() /* prevent dragging */}> +
e.stopPropagation() /* prevent dragging */}> { Map ? { onFiltering={(filtering) => this.setState({ filtering })} /> : null } -
+
{ isEditing || !isDashboard ?
Save as default view @@ -139,7 +139,7 @@ export default class PinMap extends Component<*, Props, State> { } }} > - { !this.state.filtering ? "Filter by rectange" : "Cancel filter" } + { !this.state.filtering ? "Draw box to filter" : "Cancel filter" }
}
From e5779c7c9e58e4078b43c384c96e797d0b60c803 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Mon, 24 Apr 2017 13:17:02 -0700 Subject: [PATCH 011/202] placeholder From 085422d34edd3bf468024f90c692f407d0a72a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Mon, 24 Apr 2017 14:24:11 -0700 Subject: [PATCH 012/202] Use React props instead of unstable context feature in Modal --- frontend/src/metabase/components/Modal.jsx | 35 ++++++------------- .../src/metabase/components/ModalContent.jsx | 28 ++++++--------- 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx index 69a77183c8770..662fd9fdc3791 100644 --- a/frontend/src/metabase/components/Modal.jsx +++ b/frontend/src/metabase/components/Modal.jsx @@ -13,11 +13,6 @@ import ModalContent from "./ModalContent"; import _ from "underscore"; -export const MODAL_CHILD_CONTEXT_TYPES = { - fullPageModal: PropTypes.bool, - formModal: PropTypes.bool -}; - function getModalContent(props) { if (React.Children.count(props.children) > 1 || props.title != null || props.footer != null @@ -38,15 +33,6 @@ export class WindowModal extends Component { backdropClassName: "Modal-backdrop" }; - static childContextTypes = MODAL_CHILD_CONTEXT_TYPES; - - getChildContext() { - return { - fullPageModal: false, - formModal: !!this.props.form - }; - } - componentWillMount() { this._modalElement = document.createElement('span'); this._modalElement.className = 'ModalContainer'; @@ -79,7 +65,11 @@ export class WindowModal extends Component { return (
- {getModalContent(this.props)} + { getModalContent({ + ...this.props, + fullPageModal: false, + formModal: !!this.props.form + }) }
); @@ -107,15 +97,6 @@ export class WindowModal extends Component { import routeless from "metabase/hoc/Routeless"; export class FullPageModal extends Component { - static childContextTypes = MODAL_CHILD_CONTEXT_TYPES; - - getChildContext() { - return { - fullPageModal: true, - formModal: !!this.props.form - }; - } - componentDidMount() { this._modalElement = document.createElement("div"); this._modalElement.className = "Modal--full"; @@ -165,7 +146,11 @@ export class FullPageModal extends Component { }> { motionStyle =>
- { getModalContent(this.props) } + { getModalContent({ + ...this.props, + fullPageModal: true, + formModal: !!this.props.form + }) }
} diff --git a/frontend/src/metabase/components/ModalContent.jsx b/frontend/src/metabase/components/ModalContent.jsx index b26737a2285d8..7fa08322f211a 100644 --- a/frontend/src/metabase/components/ModalContent.jsx +++ b/frontend/src/metabase/components/ModalContent.jsx @@ -1,29 +1,26 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; +import cx from "classnames"; -import { MODAL_CHILD_CONTEXT_TYPES } from "./Modal"; import Icon from "metabase/components/Icon.jsx"; -import cx from "classnames"; - import "./ModalContent.css"; export default class ModalContent extends Component { static propTypes = { id: PropTypes.string, title: PropTypes.string, - onClose: PropTypes.func.isRequired + onClose: PropTypes.func.isRequired, + fullPageModal: PropTypes.bool, + formModal: PropTypes.bool }; static defaultProps = { }; - static contextTypes = MODAL_CHILD_CONTEXT_TYPES; - render() { - const { title, footer, onClose, children, className } = this.props; + const { title, footer, onClose, children, className, fullPageModal, formModal } = this.props; - const { fullPageModal, formModal } = this.context; return (
} { title && - + {title} } - + {children} { footer && - + {footer} } @@ -57,14 +54,13 @@ export default class ModalContent extends Component { const FORM_WIDTH = 500 + 32 * 2; // includes padding -export const ModalHeader = ({ children }, { fullPageModal, formModal }) => +export const ModalHeader = ({ children, fullPageModal, formModal }) =>

{children}

-ModalHeader.contextTypes = MODAL_CHILD_CONTEXT_TYPES; -export const ModalBody = ({ children }, { fullPageModal, formModal }) => +export const ModalBody = ({ children, fullPageModal, formModal }) =>
@@ -76,9 +72,8 @@ export const ModalBody = ({ children }, { fullPageModal, formModal }) =>
-ModalBody.contextTypes = MODAL_CHILD_CONTEXT_TYPES; -export const ModalFooter = ({ children }, { fullPageModal, formModal }) => +export const ModalFooter = ({ children, fullPageModal, formModal }) =>
-ModalFooter.contextTypes = MODAL_CHILD_CONTEXT_TYPES; From 989fad886879dfe5b4a7975e927d9b0981ffde15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Mon, 24 Apr 2017 15:22:48 -0700 Subject: [PATCH 013/202] Fix misplaced header property in permissions selector --- frontend/src/metabase/admin/permissions/selectors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index efaad73d99df7..f8ffb8fc1afa0 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -283,8 +283,8 @@ export const getSchemasPermissionsGrid = createSelector( ], groups, permissions: { - header: "Data Access", "tables": { + header: "Data Access", options(groupId, entityId) { return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE] }, From 91b656057620644aa49f29b3d17e92eedbcccbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Mon, 24 Apr 2017 16:47:04 -0700 Subject: [PATCH 014/202] Rename FilterWidget => ListFilterWidget because FilterWidget exists already --- .../components/{FilterWidget.jsx => ListFilterWidget.jsx} | 2 +- frontend/src/metabase/questions/containers/EntityList.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/metabase/components/{FilterWidget.jsx => ListFilterWidget.jsx} (97%) diff --git a/frontend/src/metabase/components/FilterWidget.jsx b/frontend/src/metabase/components/ListFilterWidget.jsx similarity index 97% rename from frontend/src/metabase/components/FilterWidget.jsx rename to frontend/src/metabase/components/ListFilterWidget.jsx index e8d58f7dc76ee..59419acc91bf9 100644 --- a/frontend/src/metabase/components/FilterWidget.jsx +++ b/frontend/src/metabase/components/ListFilterWidget.jsx @@ -13,7 +13,7 @@ type FilterWidgetItem = { icon: string } -export default class FilterWidget extends Component { +export default class ListFilterWidget extends Component { props: { items: FilterWidgetItem[], activeItem: FilterWidgetItem, diff --git a/frontend/src/metabase/questions/containers/EntityList.jsx b/frontend/src/metabase/questions/containers/EntityList.jsx index 5ae812e30c76c..3e42b9c9db4ae 100644 --- a/frontend/src/metabase/questions/containers/EntityList.jsx +++ b/frontend/src/metabase/questions/containers/EntityList.jsx @@ -6,7 +6,7 @@ import { connect } from "react-redux"; import EmptyState from "metabase/components/EmptyState"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; -import FilterWidget from "metabase/components/FilterWidget" +import ListFilterWidget from "metabase/components/ListFilterWidget" import S from "../components/List.css"; @@ -205,7 +205,7 @@ export default class EntityList extends Component { null } { showEntityFilterWidget && hasEntitiesInPlainState && - item.id !== "archived")} activeItem={section} onChange={(item) => onChangeSection(item.id)} From c9566fb30b07c4894281fc49fbd720b43b7bae07 Mon Sep 17 00:00:00 2001 From: Stefano Dissegna Date: Tue, 25 Apr 2017 12:58:55 +0200 Subject: [PATCH 015/202] refactored result exports to use the same endpoint for multiple formats --- .../containers/QuestionEmbedWidget.jsx | 2 +- src/metabase/api/card.clj | 37 ++---- src/metabase/api/dataset.clj | 105 +++++++----------- src/metabase/api/embed.clj | 18 +-- src/metabase/api/public.clj | 21 +--- src/metabase/models/query_execution.clj | 4 +- src/metabase/routes.clj | 12 +- 7 files changed, 66 insertions(+), 133 deletions(-) diff --git a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx index 5ad4562fd5611..072267f530518 100644 --- a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx +++ b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx @@ -33,7 +33,7 @@ export default class QuestionEmbedWidget extends Component { onUpdateEnableEmbedding={(enableEmbedding) => updateEnableEmbedding(card, enableEmbedding)} onUpdateEmbeddingParams={(embeddingParams) => updateEmbeddingParams(card, embeddingParams)} getPublicUrl={({ public_uuid }, extension) => window.location.origin + Urls.publicCard(public_uuid, extension)} - extensions={["csv", "json"]} + extensions={["csv", "xlsx", "json"]} /> ); } diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 3cc116870ab1d..ad2fccb003bd3 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -417,36 +417,17 @@ (binding [cache/*ignore-cached-results* ignore_cache] (run-query-for-card card-id, :parameters parameters))) -(defendpoint POST "/:card-id/query/csv" - "Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" - [card-id parameters] +(defendpoint POST "/:card-id/query/:export-format-name" + "Run the query associated with a Card, and return its results as a file in the specified format. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" + [card-id export-format-name parameters] {parameters (s/maybe su/JSONString)} (binding [cache/*ignore-cached-results* true] - (dataset-api/as-csv (run-query-for-card card-id - :parameters (json/parse-string parameters keyword) - :constraints nil - :context :csv-download)))) - -(defendpoint POST "/:card-id/query/xlsx" - "Run the query associated with a Card, and return its results as XLSX. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" - [card-id parameters] - {parameters (s/maybe su/JSONString)} - (binding [cache/*ignore-cached-results* true] - (dataset-api/as-xlsx (run-query-for-card card-id - :parameters (json/parse-string parameters keyword) - :constraints nil - :context :xlsx-download)))) - -(defendpoint POST "/:card-id/query/json" - "Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" - [card-id parameters] - {parameters (s/maybe su/JSONString)} - (binding [cache/*ignore-cached-results* true] - (dataset-api/as-json (run-query-for-card card-id - :parameters (json/parse-string parameters keyword) - :constraints nil - :context :json-download)))) - + (dataset-api/as-format + export-format-name + (run-query-for-card card-id + :parameters (json/parse-string parameters keyword) + :constraints nil + :context :download)))) ;;; ------------------------------------------------------------ Sharing is Caring ------------------------------------------------------------ diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index ed0e8e15ffbba..3ce3196724991 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -1,6 +1,7 @@ (ns metabase.api.dataset "/api/dataset endpoints." (:require [clojure.data.csv :as csv] + [clojure.string :as string] [cheshire.core :as json] [compojure.core :refer [GET POST]] [dk.ative.docjure.spreadsheet :as spreadsheet] @@ -47,77 +48,53 @@ (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints default-query-constraints))) 0)}) -(defn as-csv - "Return a CSV response containing the RESULTS of a query." - {:arglists '([results])} - [{{:keys [columns rows]} :data, :keys [status], :as response}] - (if (= status :completed) - ;; successful query, send CSV file - {:status 200 - :body (with-out-str - ;; turn keywords into strings, otherwise we get colons in our output - (csv/write-csv *out* (into [(mapv name columns)] rows))) - :headers {"Content-Type" "text/csv; charset=utf-8" - "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".csv\"")}} - ;; failed query, send error message - {:status 500 - :body (:error response)})) +(defn ^:private export-to-csv + [columns rows] + (with-out-str + ;; turn keywords into strings, otherwise we get colons in our output + (csv/write-csv *out* (into [(mapv name columns)] rows)))) -(defn as-xlsx - "Return an XLSX response containing the RESULTS of a query." - {:arglists '([results])} - [{{:keys [columns rows]} :data, :keys [status], :as response}] - (if (= status :completed) - ;; successful query, send XLSX file - {:status 200 - :body (let [wb (spreadsheet/create-workbook "Query result" (conj rows (mapv name columns))) - ; note: byte array streams don't need to be closed - out (java.io.ByteArrayOutputStream.)] - (spreadsheet/save-workbook! out wb) - (java.io.ByteArrayInputStream. (.toByteArray out))) - :headers {"Content-Type" "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" - "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".xlsx\"")}} - ;; failed query, send error message - {:status 500 - :body (:error response)})) +(defn ^:private export-to-xlsx + [columns rows] + (let [wb (spreadsheet/create-workbook "Query result" (conj rows (mapv name columns))) + ;; note: byte array streams don't need to be closed + out (java.io.ByteArrayOutputStream.)] + (spreadsheet/save-workbook! out wb) + (java.io.ByteArrayInputStream. (.toByteArray out)))) -(defn as-json - "Return a JSON response containing the RESULTS of a query." - {:arglists '([results])} - [{{:keys [columns rows]} :data, :keys [status], :as response}] - (if (= status :completed) - ;; successful query, send CSV file - {:status 200 - :body (for [row rows] - (zipmap columns row)) - :headers {"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".json\"")}} - ;; failed query, send error message - {:status 500 - :body {:error (:error response)}})) +(defn ^:private export-to-json + [columns rows] + (for [row rows] + (zipmap columns row))) -(defendpoint POST "/csv" - "Execute a query and download the result data as a CSV file." - [query] - {query su/JSONString} - (let [query (json/parse-string query keyword)] - (read-check Database (:database query)) - (as-csv (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :csv-download})))) +(def ^:private export-formats + {"csv" {:export-fn export-to-csv, :content-type "text/csv", :ext "csv"}, + "xlsx" {:export-fn export-to-xlsx, :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :ext "xlsx"}, + "json" {:export-fn export-to-json, :content-type "applicaton/json", :ext "json"}}) -(defendpoint POST "/xlsx" - "Execute a query and download the result data as an XLSX file." - [query] - {query su/JSONString} - (let [query (json/parse-string query keyword)] - (read-check Database (:database query)) - (as-xlsx (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :xlsx-download})))) +(defn as-format + "Return a response containing the RESULTS of a query in the specified format." + {:arglists '([export-format-name results])} + [export-format-name {{:keys [columns rows]} :data, :keys [status], :as response}] + (let-404 [export-format (export-formats export-format-name)] + (if (= status :completed) + ;; successful query, send file + {:status 200 + :body ((:export-fn export-format) columns rows) + :headers {"Content-Type" (str (:content-type export-format) "; charset=utf-8") + "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) "." (:ext export-format) "\"")}} + ;; failed query, send error message + {:status 500 + :body (:error response)}))) -(defendpoint POST "/json" - "Execute a query and download the result data as a JSON file." - [query] +(def ^:private export-format-name-regex (re-pattern (str "(" (string/join "|" (keys export-formats)) ")"))) + +(defendpoint POST ["/:export-format-name", :export-format-name export-format-name-regex] + "Execute a query and download the result data as a file in the specified format." + [export-format-name query] {query su/JSONString} (let [query (json/parse-string query keyword)] (read-check Database (:database query)) - (as-json (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :json-download})))) - + (as-format export-format-name (qp/dataset-query (dissoc query :constraints) {:executed-by *current-user-id*, :context :download})))) (define-routes) diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index b00ab4c396a84..55b2af26bb892 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -274,20 +274,10 @@ (run-query-for-unsigned-token (eu/unsign token) query-params)) -(api/defendpoint GET "/card/:token/query/csv" - "Like `GET /api/embed/card/query`, but returns the results as CSV." - [token & query-params] - (dataset-api/as-csv (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) - -(api/defendpoint GET "/card/:token/query/xlsx" - "Like `GET /api/embed/card/query`, but returns the results as XLSX." - [token & query-params] - (dataset-api/as-xlsx (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) - -(api/defendpoint GET "/card/:token/query/json" - "Like `GET /api/embed/card/query`, but returns the results as JSOn." - [token & query-params] - (dataset-api/as-json (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) +(api/defendpoint GET "/card/:token/query/:export-format-name" + "Like `GET /api/embed/card/query`, but returns the results as a file in the specified format." + [token export-format-name & query-params] + (dataset-api/as-format export-format-name (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) ;;; ------------------------------------------------------------ /api/embed/dashboard endpoints ------------------------------------------------------------ diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index dd3a953ea1bec..d01271a053df3 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -75,24 +75,11 @@ {parameters (s/maybe su/JSONString)} (run-query-for-card-with-public-uuid uuid parameters)) -(api/defendpoint GET "/card/:uuid/query/json" - "Fetch a publically-accessible Card and return query results as JSON. Does not require auth credentials. Public sharing must be enabled." - [uuid parameters] - {parameters (s/maybe su/JSONString)} - (dataset-api/as-json (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) - -(api/defendpoint GET "/card/:uuid/query/csv" - "Fetch a publically-accessible Card and return query results as CSV. Does not require auth credentials. Public sharing must be enabled." - [uuid parameters] +(api/defendpoint GET "/card/:uuid/query/:export-format-name" + "Fetch a publically-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled." + [uuid export-format-name parameters] {parameters (s/maybe su/JSONString)} - (dataset-api/as-csv (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) - -(api/defendpoint GET "/card/:uuid/query/xlsx" - "Fetch a publically-accessible Card and return query results as XLSX. Does not require auth credentials. Public sharing must be enabled." - [uuid parameters] - {parameters (s/maybe su/JSONString)} - (dataset-api/as-xlsx (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) - + (dataset-api/as-format export-format-name (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) ;;; ------------------------------------------------------------ Public Dashboards ------------------------------------------------------------ diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj index 1dd4a88b9e63d..94e310047125c 100644 --- a/src/metabase/models/query_execution.clj +++ b/src/metabase/models/query_execution.clj @@ -15,12 +15,10 @@ (def Context "Schema for valid values of QueryExecution `:context`." (s/enum :ad-hoc - :csv-download - :xlsx-download + :download :dashboard :embedded-dashboard :embedded-question - :json-download :map-tiles :metabot :public-dashboard diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index 7f59454fd3302..72046249fb56d 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -33,15 +33,15 @@ (def ^:private embed (partial entrypoint "embed" :embeddable)) (defroutes ^:private public-routes - (GET ["/question/:uuid.csv" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/csv" uuid))) - (GET ["/question/:uuid.xlsx" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/xlsx" uuid))) - (GET ["/question/:uuid.json" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/json" uuid))) + (GET ["/question/:uuid.:export-format-name" :uuid u/uuid-regex] + [uuid export-format-name] + (resp/redirect (format "/api/public/card/%s/query/%s" uuid export-format-name))) (GET "*" [] public)) (defroutes ^:private embed-routes - (GET "/question/:token.csv" [token] (resp/redirect (format "/api/embed/card/%s/query/csv" token))) - (GET "/question/:token.xlsx" [token] (resp/redirect (format "/api/embed/card/%s/query/xlsx" token))) - (GET "/question/:token.json" [token] (resp/redirect (format "/api/embed/card/%s/query/json" token))) + (GET "/question/:token.:export-format-name" + [token export-format-name] + (resp/redirect (format "/api/embed/card/%s/query/%s" token export-format-name))) (GET "*" [] embed)) ;; Redirect naughty users who try to visit a page other than setup if setup is not yet complete From a9965aad8c66473c55698f4949a47b99058166fb Mon Sep 17 00:00:00 2001 From: Stefano Dissegna Date: Tue, 25 Apr 2017 14:23:46 +0200 Subject: [PATCH 016/202] xlsx export tests --- test/metabase/api/card_test.clj | 15 ++++++++++++++- test/metabase/api/embed_test.clj | 7 ++++--- test/metabase/api/public_test.clj | 8 +++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index a42630ea44f06..56a9fd087c107 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -397,8 +397,15 @@ (perms/grant-native-read-permissions! (perms-group/all-users) database-id) ((user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card)))))) +;;; Tests for GET /api/card/:id/xlsx +(expect + #(> (.length %1) 0) + (do-with-temp-native-card + (fn [database-id card] + (perms/grant-native-read-permissions! (perms-group/all-users) database-id) + ((user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)))))) -;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json **WITH PARAMETERS** +;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json & GET /api/card/:id/query/xlsx **WITH PARAMETERS** (defn- do-with-temp-native-card-with-params {:style/indent 0} [f] (tt/with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}] @@ -433,6 +440,12 @@ (fn [database-id card] ((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params))))) +;; XLSX +(expect + #(> (.length %1) 0) + (do-with-temp-native-card-with-params + (fn [database-id card] + ((user->client :rasta) :post 200 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params))))) ;;; +------------------------------------------------------------------------------------------------------------------------+ ;;; | COLLECTIONS | diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 8abbdd460aa14..831f4c7fca7b8 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -61,7 +61,8 @@ (case results-format "" (successful-query-results) "/json" [{:count 100}] - "/csv" "count\n100\n"))) + "/csv" "count\n100\n" + "/xlsx" #(> (.length %1) 0)))) (defn dissoc-id-and-name {:style/indent 0} [obj] (dissoc obj :id :name)) @@ -127,7 +128,7 @@ (:parameters (http/client :get 200 (card-url card {:params {:c 100}})))))) -;; ------------------------------------------------------------ GET /api/embed/card/:token/query (and JSON and CSV variants) ------------------------------------------------------------ +;; ------------------------------------------------------------ GET /api/embed/card/:token/query (and JSON/CSV/XLSX variants) ------------------------------------------------------------ (defn- card-query-url [card response-format & [additional-token-params]] (str "embed/card/" @@ -137,7 +138,7 @@ (defmacro ^:private expect-for-response-formats {:style/indent 1} [[response-format-binding] expected actual] `(do - ~@(for [response-format ["" "/json" "/csv"]] + ~@(for [response-format ["" "/json" "/csv" "/xlsx"]] `(expect (let [~response-format-binding ~response-format] ~expected) diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index d7e49595a801b..531cc71af99f8 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -89,7 +89,7 @@ (set (keys (http/client :get 200 (str "public/card/" uuid))))))) -;;; ------------------------------------------------------------ GET /api/public/card/:uuid/query (and JSON and CSV versions) ------------------------------------------------------------ +;;; ------------------------------------------------------------ GET /api/public/card/:uuid/query (and JSON/CSV/XSLX versions) ------------------------------------------------------------ ;; Check that we *cannot* execute a PublicCard if the setting is disabled (expect @@ -132,6 +132,12 @@ (with-temp-public-card [{uuid :public_uuid}] (http/client :get 200 (str "public/card/" uuid "/query/csv"), :format :csv)))) +;; Check that we can exec a PublicCard and get results as XLSX +(expect + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (http/client :get 200 (str "public/card/" uuid "/query/xlsx"))))) + ;; Check that we can exec a PublicCard with `?parameters` (expect [{:type "category", :value 2}] From 764546034a59238afe450c6d4c9f33883bf80994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 10:30:18 -0700 Subject: [PATCH 017/202] Add a filter list and ability to change its value --- .../metabase/components/ListFilterWidget.jsx | 8 +-- .../dashboards/containers/Dashboards.jsx | 53 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/frontend/src/metabase/components/ListFilterWidget.jsx b/frontend/src/metabase/components/ListFilterWidget.jsx index 59419acc91bf9..83f5f789968f4 100644 --- a/frontend/src/metabase/components/ListFilterWidget.jsx +++ b/frontend/src/metabase/components/ListFilterWidget.jsx @@ -7,7 +7,7 @@ import React, { Component } from "react"; import Icon from "metabase/components/Icon"; import PopoverWithTrigger from "./PopoverWithTrigger"; -type FilterWidgetItem = { +export type ListFilterWidgetItem = { id: string, name: string, icon: string @@ -15,9 +15,9 @@ type FilterWidgetItem = { export default class ListFilterWidget extends Component { props: { - items: FilterWidgetItem[], - activeItem: FilterWidgetItem, - onChange: (FilterWidgetItem) => void + items: ListFilterWidgetItem[], + activeItem: ListFilterWidgetItem, + onChange: (ListFilterWidgetItem) => void }; popoverRef: PopoverWithTrigger; diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index becdad46d02ad..05d0b8a2041f2 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -15,6 +15,8 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import Icon from "metabase/components/Icon.jsx"; import SearchHeader from "metabase/components/SearchHeader"; import EmptyState from "metabase/components/EmptyState"; +import ListFilterWidget from "metabase/components/ListFilterWidget"; +import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; import {caseInsensitiveSearch} from "metabase/lib/string" @@ -28,16 +30,50 @@ const mapStateToProps = (state, props) => ({ const mapDispatchToProps = dashboardsActions; +const SECTIONS: ListFilterWidgetItem[] = [ + { + id: 'all', + name: 'All dashboards', + icon: 'all', + // empty: 'No questions have been saved yet.', + }, + // { + // id: 'fav', + // name: 'Favorites', + // icon: 'star', + // // empty: 'You haven\'t favorited any questions yet.', + // }, + // { + // id: 'recent', + // name: 'Recently viewed', + // icon: 'recents', + // // empty: 'You haven\'t viewed any questions recently.', + // }, + { + id: 'mine', + name: 'Saved by me', + icon: 'mine', + // empty: 'You haven\'t saved any questions yet.' + }, + // { + // id: 'popular', + // name: 'Most popular', + // icon: 'popular', + // // empty: 'The most viewed questions across your company will show up here.', + // } +]; + export class Dashboards extends Component { props: { dashboards: Dashboard[], createDashboard: (Dashboard) => any, - fetchDashboards: PropTypes.func.isRequired, + fetchDashboards: () => void, }; state = { modalOpen: false, - searchText: "" + searchText: "", + section: SECTIONS[0] } componentWillMount() { @@ -85,8 +121,12 @@ export class Dashboards extends Component { } } + updateSection = (section: ListFilterWidgetItem) => { + this.setState({section}); + } + render() { - let {modalOpen, searchText} = this.state; + let {modalOpen, searchText, section} = this.state; const isLoading = this.props.dashboards === null const noDashboardsCreated = this.props.dashboards && this.props.dashboards.length === 0 @@ -124,6 +164,13 @@ export class Dashboards extends Component { searchText={searchText} setSearchText={(text) => this.setState({searchText: text})} /> +
+ item.id !== "archived")} + activeItem={section} + onChange={this.updateSection} + /> +
{ noSearchResults ?
From b8e6a27b63e7b835b9de338631eef5f705bd6d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 11:06:47 -0700 Subject: [PATCH 018/202] Create section filter for showing user's own dashboards --- .../dashboards/containers/Dashboards.jsx | 40 +++++++++++++------ frontend/src/metabase/meta/types/Dashboard.js | 1 + 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 05d0b8a2041f2..0b4abe29a1df4 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -1,8 +1,9 @@ /* @flow */ -import React, {Component, PropTypes} from 'react'; +import React, {Component} from 'react'; import {connect} from "react-redux"; import cx from "classnames"; +import _ from "underscore" import type {Dashboard} from "metabase/meta/types/Dashboard"; @@ -22,17 +23,21 @@ import {caseInsensitiveSearch} from "metabase/lib/string" import * as dashboardsActions from "../dashboards"; import {getDashboardListing} from "../selectors"; - +import {getUser} from "metabase/selectors/user"; const mapStateToProps = (state, props) => ({ - dashboards: getDashboardListing(state) + dashboards: getDashboardListing(state), + user: getUser(state) }); const mapDispatchToProps = dashboardsActions; +const SECTION_ID_ALL = 'all'; +const SECTION_ID_MINE = 'mine' + const SECTIONS: ListFilterWidgetItem[] = [ { - id: 'all', + id: SECTION_ID_ALL, name: 'All dashboards', icon: 'all', // empty: 'No questions have been saved yet.', @@ -50,7 +55,7 @@ const SECTIONS: ListFilterWidgetItem[] = [ // // empty: 'You haven\'t viewed any questions recently.', // }, { - id: 'mine', + id: SECTION_ID_MINE, name: 'Saved by me', icon: 'mine', // empty: 'You haven\'t saved any questions yet.' @@ -108,17 +113,26 @@ export class Dashboards extends Component { ); } + /* Returns a boolean indicating whether the search term was found from dashboard name or description */ + searchTextFilter = (searchText: string) => + ({name, description}: Dashboard) => + (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) + + /* Returns a boolean indicating whether the dashboard belongs to the specified section or not */ + sectionFilter = (section: ListFilterWidgetItem) => + ({creator_id}: Dashboard) => + (section.id === SECTION_ID_ALL) || + (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) + getFilteredDashboards = () => { - const {searchText} = this.state; + const {searchText, section} = this.state; const {dashboards} = this.props; + const noOpFilter = _.constant(true) - if (searchText === "") { - return dashboards; - } else { - return dashboards.filter(({name, description}) => - caseInsensitiveSearch(name,searchText) || (description && caseInsensitiveSearch(description, searchText)) - ); - } + return _.chain(dashboards) + .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) + .filter(this.sectionFilter(section)) + .value() } updateSection = (section: ListFilterWidgetItem) => { diff --git a/frontend/src/metabase/meta/types/Dashboard.js b/frontend/src/metabase/meta/types/Dashboard.js index 1d8034a817e08..5d951329739fa 100644 --- a/frontend/src/metabase/meta/types/Dashboard.js +++ b/frontend/src/metabase/meta/types/Dashboard.js @@ -10,6 +10,7 @@ export type Dashboard = { id: DashboardId, name: string, created_at: ?string, + creator_id: number, description: ?string, caveats?: string, points_of_interest?: string, From 7dde285ff9da78eeccc0af8f6240945658c86785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 12:47:10 -0700 Subject: [PATCH 019/202] Favorites filter, rudimentary favoriting button --- frontend/src/metabase/css/core/colors.css | 1 + .../dashboards/components/DashboardList.jsx | 29 +++++++++++++++---- .../dashboards/containers/Dashboards.jsx | 20 +++++++------ frontend/src/metabase/meta/types/Dashboard.js | 1 + 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index 0242b1d3f1481..f6a44141a50ea 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -191,3 +191,4 @@ color: #CFE4F5 } .text-slate { color: #606E7B; } + diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 50ed2de2e2616..dae55f35f28f7 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -12,15 +12,17 @@ import * as Urls from "metabase/lib/urls"; import type {Dashboard} from "metabase/meta/types/Dashboard"; import Icon from "metabase/components/Icon"; import Ellipsified from "metabase/components/Ellipsified.jsx"; +import Tooltip from "metabase/components/Tooltip"; type DashboardListItemType = { dashboard: Dashboard, hover: boolean, - setHover: (boolean) => void + setHover: (boolean) => void, + setFavorited: (id: number, favorited: boolean) => void } const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, hover, setHover}: DashboardListItemType) => +const DashboardListItem = enhance(({dashboard, setFavorited, hover, setHover}: DashboardListItemType) =>
  • setHover(false)}> -
    +
    + { hover && + + setFavorited(dashboard.id, !dashboard.favorite) } + /> + + }

    + style={{marginBottom: "0.2em", marginRight: hover ? "25px" : 0}}> {dashboard.name}

    - { dashboards.map(dash => )} + { dashboards.map(dash => )} ); } diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 0b4abe29a1df4..96751c3e94c4b 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -33,7 +33,8 @@ const mapStateToProps = (state, props) => ({ const mapDispatchToProps = dashboardsActions; const SECTION_ID_ALL = 'all'; -const SECTION_ID_MINE = 'mine' +const SECTION_ID_MINE = 'mine'; +const SECTION_ID_FAVORITES = 'fav'; const SECTIONS: ListFilterWidgetItem[] = [ { @@ -42,12 +43,12 @@ const SECTIONS: ListFilterWidgetItem[] = [ icon: 'all', // empty: 'No questions have been saved yet.', }, - // { - // id: 'fav', - // name: 'Favorites', - // icon: 'star', - // // empty: 'You haven\'t favorited any questions yet.', - // }, + { + id: SECTION_ID_FAVORITES, + name: 'Favorites', + icon: 'star', + // empty: 'You haven\'t favorited any questions yet.', + }, // { // id: 'recent', // name: 'Recently viewed', @@ -120,9 +121,10 @@ export class Dashboards extends Component { /* Returns a boolean indicating whether the dashboard belongs to the specified section or not */ sectionFilter = (section: ListFilterWidgetItem) => - ({creator_id}: Dashboard) => + ({creator_id, favorite}: Dashboard) => (section.id === SECTION_ID_ALL) || - (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) + (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) || + (section.id === SECTION_ID_FAVORITES && favorite === true) getFilteredDashboards = () => { const {searchText, section} = this.state; diff --git a/frontend/src/metabase/meta/types/Dashboard.js b/frontend/src/metabase/meta/types/Dashboard.js index 5d951329739fa..6c71b75770f37 100644 --- a/frontend/src/metabase/meta/types/Dashboard.js +++ b/frontend/src/metabase/meta/types/Dashboard.js @@ -9,6 +9,7 @@ export type DashboardId = number; export type Dashboard = { id: DashboardId, name: string, + favorite: boolean, created_at: ?string, creator_id: number, description: ?string, From 00751d39784bcf5fd42f0931176a9dbc1230571f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 13:04:54 -0700 Subject: [PATCH 020/202] Polish, massage and nurture the favorite button --- .../dashboards/components/DashboardList.jsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index dae55f35f28f7..3568633492eed 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -40,29 +40,31 @@ const DashboardListItem = enhance(({dashboard, setFavorited, hover, setHover}: D onMouseLeave={() => setHover(false)}> -
    - { hover && - - setFavorited(dashboard.id, !dashboard.favorite) } - /> - - } -

    - {dashboard.name} -

    -
    - {/* NOTE: Could these time formats be centrally stored somewhere? */} - {moment(dashboard.created_at).format('MMM D, YYYY')} +
    +
    +
    {/* first demo to maz: remove flex-full */} +

    + {dashboard.name} +

    +
    + {/* NOTE: Could these time formats be centrally stored somewhere? */} + {moment(dashboard.created_at).format('MMM D, YYYY')} +
    +
    + + setFavorited(dashboard.id, !dashboard.favorite) } + /> +
    From d00cb5bcee4a2c29848c194e096b84d3b555dfa2 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Tue, 25 Apr 2017 13:50:30 -0700 Subject: [PATCH 021/202] Revert "Right click prototype (only on scalar and table for now)" This reverts commit c46c18f77ed91a645f03e0940e67d92b8523208f. --- .../components/TableInteractive.jsx | 17 ++++++---------- .../components/Visualization.jsx | 20 ++++++------------- .../visualizations/visualizations/Scalar.jsx | 4 +--- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 17868a4b61e4b..af8a77121c4f6 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -245,9 +245,6 @@ export default class TableInteractive extends Component<*, Props, State> { } const isClickable = onVisualizationClick && visualizationIsClickable(clicked); - const onClick = isClickable && ((e) => { - onVisualizationClick({ ...clicked, element: e.currentTarget, e: e.nativeEvent }); - }) return (
    { "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, "cursor-pointer": isClickable })} - onClick={onClick} - onContextMenu={onClick} + onClick={isClickable && ((e) => { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + })} >
    @@ -289,10 +287,6 @@ export default class TableInteractive extends Component<*, Props, State> { } const isClickable = onVisualizationClick && visualizationIsClickable(clicked); - const onClick = isClickable && ((e) => { - onVisualizationClick({ ...clicked, element: e.currentTarget, e: e.nativeEvent }); - }) - const isSortable = isClickable && column.source; return ( @@ -306,8 +300,9 @@ export default class TableInteractive extends Component<*, Props, State> { >
    { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + })} > {columnTitle} {isSortable && diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 68cb7feddc065..bfc935a93c8a4 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -204,22 +204,14 @@ export default class Visualization extends Component<*, Props, State> { } handleVisualizationClick = (clicked: ClickObject) => { - const { onChangeCardAndRun } = this.props; - - const clickActions = this.getClickActions(clicked); - const defaultClickActions = clickActions.filter(a => a.default); - - const wasRightClick = clicked.e && clicked.e.type === "contextmenu"; - - if (wasRightClick && clickActions.length > 0) { - clicked.e.preventDefault(); - } - // needs to be delayed so we don't clear it when switching from one drill through to another setTimeout(() => { - if (!wasRightClick && defaultClickActions.length === 1 && defaultClickActions[0].card) { - onChangeCardAndRun(defaultClickActions[0].card()); - } else if (clickActions.length > 0) { + 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) diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 4662e69b4ccb0..f55135518c34f 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -173,7 +173,6 @@ export default class Scalar extends Component<*, VisualizationProps, *> { column: cols[0] }; const isClickable = visualizationIsClickable(clicked); - const onClick = isClickable && ((e) => this._scalar && onVisualizationClick({ ...clicked, element: this._scalar, e: e.nativeEvent })) return (
    @@ -187,8 +186,7 @@ export default class Scalar extends Component<*, VisualizationProps, *> { style={{maxWidth: '100%'}} > this._scalar && onVisualizationClick({ ...clicked, element: this._scalar }))} ref={scalar => this._scalar = scalar} > {compactScalarValue} From 7d4ebc2cb2b661868461d6dc5f3426ec662301b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 14:50:10 -0700 Subject: [PATCH 022/202] Mock the favoriting API call --- .../dashboards/components/DashboardList.jsx | 36 +++++++++++-------- .../dashboards/containers/Dashboards.jsx | 3 +- .../src/metabase/dashboards/dashboards.js | 16 ++++++++- frontend/src/metabase/services.js | 2 ++ 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 3568633492eed..1eb4364e968c1 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -41,8 +41,8 @@ const DashboardListItem = enhance(({dashboard, setFavorited, hover, setHover}: D
    -
    -
    {/* first demo to maz: remove flex-full */} +
    +

    {dashboard.name} @@ -53,18 +53,26 @@ const DashboardListItem = enhance(({dashboard, setFavorited, hover, setHover}: D {moment(dashboard.created_at).format('MMM D, YYYY')}

    - - setFavorited(dashboard.id, !dashboard.favorite) } - /> - + { (dashboard.favorite || hover) && +
    + + { + e.preventDefault(); + setFavorited(dashboard.id, !dashboard.favorite) + } } + /> + +
    + }
    diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 96751c3e94c4b..3477aca5c7250 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -74,6 +74,7 @@ export class Dashboards extends Component { dashboards: Dashboard[], createDashboard: (Dashboard) => any, fetchDashboards: () => void, + setFavorited: (dashboardId: number, favorited: boolean) => void }; state = { @@ -204,7 +205,7 @@ export class Dashboards extends Component { smallDescription />
    - : + : }
    diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index cd8bf38dc8da1..b9700f9bf27df 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -12,8 +12,9 @@ import type { Dashboard } from "metabase/meta/types/Dashboard"; export const FETCH_DASHBOARDS = "metabase/dashboards/FETCH_DASHBOARDS"; export const CREATE_DASHBOARD = "metabase/dashboards/CREATE_DASHBOARD"; export const DELETE_DASHBOARD = "metabase/dashboards/DELETE_DASHBOARD"; -export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; +export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; export const UPDATE_DASHBOARD = "metabase/dashboards/UPDATE_DASHBOARD"; +export const SET_FAVORITED = "metabase/dashboards/SET_FAVORITED"; /** * Actions that retrieve/update the basic information of dashboards @@ -92,12 +93,25 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashboar }; }); +export const setFavorited = createThunkAction(SET_FAVORITED, (dashId, favorited) => { + return async (dispatch, getState) => { + if (favorited) { + // await DashboardApi.favorite({ dashId }); + } else { + // await DashboardApi.unfavorite({ dashId }); + } + MetabaseAnalytics.trackEvent("Dashboard", favorited ? "Favorite" : "Unfavorite"); + return { id: dashId, favorite: favorited }; + } +}); + const dashboardListing = handleActions({ [FETCH_DASHBOARDS]: (state, { payload }) => payload, [CREATE_DASHBOARD]: (state, { payload }) => (state || []).concat(payload), [DELETE_DASHBOARD]: (state, { payload }) => (state || []).filter(d => d.id !== payload), [SAVE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), [UPDATE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), + [SET_FAVORITED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, favorite: payload.favorite} : d) }, null); export default combineReducers({ diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 7705b6c16e9de..8ce87d75cc03b 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -45,6 +45,8 @@ export const DashboardApi = { addcard: POST("/api/dashboard/:dashId/cards"), removecard: DELETE("/api/dashboard/:dashId/cards"), reposition_cards: PUT("/api/dashboard/:dashId/cards"), + favorite: POST("/api/dashboard/:dashId/favorite"), + unfavorite: DELETE("/api/dashboard/:dashId/favorite"), listPublic: GET("/api/dashboard/public"), listEmbeddable: GET("/api/dashboard/embeddable"), From 8749d52a9f9ae69de0807c8b72dc1d5991e8083f Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Tue, 25 Apr 2017 16:06:36 -0700 Subject: [PATCH 023/202] New drill design, mostly complete. Needs correct icons, and a few stray actions need correct titles/sections. --- frontend/src/metabase/css/core/colors.css | 4 + .../qb/components/actions/PivotByAction.jsx | 5 +- .../qb/components/drill/ObjectDetailDrill.jsx | 11 +- .../qb/components/drill/QuickFilterDrill.jsx | 46 +------- .../qb/components/drill/SortAction.jsx | 85 +++++++-------- .../components/drill/SummarizeColumnDrill.js | 29 +++-- .../components/drill/TimeseriesPivotDrill.jsx | 11 +- .../drill/UnderlyingRecordsDrill.jsx | 11 +- .../components/ChartClickActions.jsx | 103 +++++++++++++----- .../components/Visualization.jsx | 15 ++- 10 files changed, 168 insertions(+), 152 deletions(-) diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index 0242b1d3f1481..f12b2b67e9e14 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -33,6 +33,10 @@ color: var(--default-font-color); } +.text-default-hover:hover { + color: var(--default-font-color); +} + .text-danger { color: #EEA5A5; } /* brand */ diff --git a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx index 027434a8f7289..55dbb0ec0e9a1 100644 --- a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx +++ b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx @@ -66,7 +66,10 @@ export default (name: string, icon: string, fieldFilter: FieldFilter) => return [ { - title: ( + section: "breakout", + title: clicked ? + name + : ( Pivot by {" "} diff --git a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx index bcfeb23628a2d..c79bd847dcc5a 100644 --- a/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx @@ -44,15 +44,8 @@ export default ( return [ { - title: ( - - View this - {" "} - - {singularize(stripId(recordType))} - - - ), + section: "details", + title: "View details", default: true, card: () => drillRecord(tableMetadata.db_id, table.id, field.id, value) diff --git a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx index 5230d9e46889a..5002ddc31d935 100644 --- a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx @@ -62,44 +62,10 @@ export default ( ]; } - let operators = getFiltersForColumn(column); - if (!operators || operators.length === 0) { - return []; - } - - return [ - { - title: ( - - Filter by this value - - ), - default: true, - popover({ onChangeCardAndRun, onClose }) { - return ( -
      - {operators && - operators.map(({ name, operator }) => ( -
    • { - onChangeCardAndRun( - filter( - card, - operator, - column, - value - ) - ); - }} - > - {name} -
    • - ))} -
    - ); - } - } - ]; + let operators = getFiltersForColumn(column) || []; + return operators.map(({ name, operator }) => ({ + section: "filter", + title: {name}, + card: () => filter(card, operator, column, value) + })); }; diff --git a/frontend/src/metabase/qb/components/drill/SortAction.jsx b/frontend/src/metabase/qb/components/drill/SortAction.jsx index 48f4228c6610a..201fa7635fc68 100644 --- a/frontend/src/metabase/qb/components/drill/SortAction.jsx +++ b/frontend/src/metabase/qb/components/drill/SortAction.jsx @@ -2,7 +2,7 @@ import React from "react"; -import { assocIn } from "icepick"; +import { assocIn, getIn } from "icepick"; import Query from "metabase/lib/query"; import * as Card from "metabase/meta/Card"; @@ -27,49 +27,48 @@ export default ( } const { column } = clicked; - return [ - { - title: ( - - Sort by {column.display_name} - - ), - 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; - } + const field = getFieldFromColumn(column, query); - let sortClause = [field, "ascending"]; + const [sortField, sortDirection] = getIn(query, ["order_by", 0]) || []; + const isAlreadySorted = sortField != null && Query.isSameField(sortField, field); - 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"]; - } + const actions = []; + if (!isAlreadySorted || sortDirection === "descending" || sortDirection === "desc") { + actions.push({ + title: "Ascending", + section: "sort", + card: () => assocIn( + card, + ["dataset_query", "query", "order_by"], + [[field, "ascending"]] + ) + }) + } + if (!isAlreadySorted || sortDirection === "ascending" || sortDirection === "asc") { + actions.push({ + title: "Descending", + section: "sort", + card: () => assocIn( + card, + ["dataset_query", "query", "order_by"], + [[field, "descending"]] + ) + }); + } + return actions; +}; - // set clause - return assocIn( - card, - ["dataset_query", "query", "order_by"], - [sortClause] - ); - } +function getFieldFromColumn(column, query) { + 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) { + return ["expression", column.display_name]; + } else { + return ["aggregation", 0]; } - ]; -}; + } else { + return column.id; + } +} diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js index 559b7d25a1cf2..c5073a71704db 100644 --- a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js @@ -12,11 +12,26 @@ import type { } from "metabase/meta/types/Visualization"; const AGGREGATIONS = { - min: "Minimum", - max: "Maximum", - avg: "Average", - sum: "Sum", - distinct: "Distinct Values" + sum: { + section: "distribution", + title: "Sum", + }, + avg: { + section: "aggregation", + title: "Avg", + }, + min: { + section: "aggregation", + title: "Min", + }, + max: { + section: "aggregation", + title: "Max", + }, + distinct: { + section: "aggregation", + title: "Distincts", + }, }; export default ( @@ -36,8 +51,8 @@ export default ( } const { column } = clicked; - return Object.entries(AGGREGATIONS).map(([aggregation, name]) => ({ - title: {name} of {column.display_name}, + return Object.entries(AGGREGATIONS).map(([aggregation, action]) => ({ + ...action, card: () => summarize( card, diff --git a/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx index cbff49daf15cd..662082e696c78 100644 --- a/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/TimeseriesPivotDrill.jsx @@ -20,15 +20,8 @@ export default ( return [ { - title: ( - - Drill into this - {" "} - - {drilldown.name} - - - ), + section: "zoom", + title: "Zoom in", 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 index 13768c4b5ab4f..cc0fc47046aea 100644 --- a/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx @@ -24,15 +24,8 @@ export default ( return [ { - title: ( - - View {inflect("these", count, "this", "these")} - {" "} - - {inflect(tableMetadata.display_name, count)} - - - ), + section: "zoom", + title: "View rows", card: () => drillUnderlyingRecords(card, dimensions) } ]; diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index 87c0c1d05c003..de4b57cb0bec4 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -2,12 +2,45 @@ import React, { Component } from "react"; -import Button from "metabase/components/Button"; +import Icon from "metabase/components/Icon"; import Popover from "metabase/components/Popover"; import type { ClickObject, ClickAction } from "metabase/meta/types/Visualization"; import type { Card } from "metabase/meta/types/Card"; +import _ from "underscore"; + +const SECTIONS = { + zoom: { + icon: "search" + }, + details: { + icon: "document" + }, + sort: { + icon: "expand" + }, + breakout: { + icon: "connections" + }, + distribution: { + icon: "number" + }, + aggregation: { + icon: "line" + }, + filter: { + icon: "funnel" + }, + dashboard: { + icon: "dashboard" + } +} +// give them indexes so we can sort the sections by the above ordering (JS objects are ordered) +Object.values(SECTIONS).map((section, index) => { + section.index = index; +}); + type Props = { clicked: ClickObject, clickActions: ?ClickAction[], @@ -16,21 +49,31 @@ type Props = { }; type State = { - popoverIndex: ?number; + popoverAction: ?ClickAction; } export default class ChartClickActions extends Component<*, Props, State> { state: State = { - popoverIndex: null + popoverAction: null }; close = () => { - this.setState({ popoverIndex: null }); + this.setState({ popoverAction: null }); if (this.props.onClose) { this.props.onClose(); } } + handleClickAction = (action) => { + const { onChangeCardAndRun } = this.props; + if (action.popover) { + this.setState({ popoverAction: action }); + } else if (action.card) { + onChangeCardAndRun(action.card()); + this.close(); + } + } + render() { const { clicked, clickActions, onChangeCardAndRun } = this.props; @@ -38,14 +81,10 @@ export default class ChartClickActions extends Component<*, Props, State> { return null; } - let { popoverIndex } = this.state; - if (clickActions.length === 1 && clickActions[0].popover && clickActions[0].default) { - popoverIndex = 0; - } - + let { popoverAction } = this.state; let popover; - if (popoverIndex != null && clickActions[popoverIndex].popover) { - const PopoverContent = clickActions[popoverIndex].popover; + if (popoverAction && popoverAction.popover) { + const PopoverContent = popoverAction.popover; popover = ( { ); } + const sections = _.chain(clickActions) + .groupBy("section") + .pairs() + .sortBy(([key]) => SECTIONS[key] ? SECTIONS[key].index : 99) + .value(); + return ( { popover ? popover : -
    - { clickActions.map((action, index) => - +
    + { actions.map((action, index) => +
    this.handleClickAction(action)} + > + {action.title} +
    + )} +
    )}
    } diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index bfc935a93c8a4..17ecac6e9716f 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -204,16 +204,17 @@ export default class Visualization extends Component<*, Props, State> { } handleVisualizationClick = (clicked: ClickObject) => { + console.log("clicked", clicked) // 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); + // 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 { + // if (clickActions.length === 1 && clickActions[0].default && clickActions[0].card) { + // onChangeCardAndRun(clickActions[0].card()); + // } else { this.setState({ clicked }); - } + // } }, 100) } @@ -295,6 +296,8 @@ export default class Visualization extends Component<*, Props, State> { }; } + console.log("clicked", clicked); + return (
    { showTitle && (settings["card.title"] || extra) && (loading || error || noResults || !(CardVisualization && CardVisualization.noHeader)) || replacementContent ? From 0d176ccb3deb3b21170fda25b8c18b50201ac293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 16:25:37 -0700 Subject: [PATCH 024/202] Dashboard archival with undo --- .../dashboards/components/DashboardList.jsx | 34 +++++++++---- .../dashboards/containers/Dashboards.jsx | 22 +++++--- .../src/metabase/dashboards/dashboards.js | 51 +++++++++++++++++-- frontend/src/metabase/meta/types/Dashboard.js | 1 + 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 1eb4364e968c1..95ec469394aab 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -18,11 +18,12 @@ type DashboardListItemType = { dashboard: Dashboard, hover: boolean, setHover: (boolean) => void, - setFavorited: (id: number, favorited: boolean) => void + setFavorited: (dashId: number, favorited: boolean) => void, + setArchived: (dashId: number, archived: boolean) => void } const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, setFavorited, hover, setHover}: DashboardListItemType) => +const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover}: DashboardListItemType) =>
  • - { (dashboard.favorite || hover) && -
    +
    + { (dashboard.archived || hover) && + + { + e.preventDefault(); + setArchived(dashboard.id, !dashboard.archived, true) + } } + /> + + } + { (dashboard.favorite || hover) && + }
    - }
    @@ -85,12 +98,15 @@ export default class DashboardList extends Component { }; render() { - const {dashboards, setFavorited} = this.props; + const {dashboards, setFavorited, setArchived} = this.props; return (
      - { dashboards.map(dash => )} + { dashboards.map(dash => + + )}
    ); } diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 3477aca5c7250..a1ebc0c587014 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -21,10 +21,12 @@ import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; import {caseInsensitiveSearch} from "metabase/lib/string" +import type {SetFavoritedAction, SetArchivedAction} from "../dashboards"; import * as dashboardsActions from "../dashboards"; import {getDashboardListing} from "../selectors"; import {getUser} from "metabase/selectors/user"; + const mapStateToProps = (state, props) => ({ dashboards: getDashboardListing(state), user: getUser(state) @@ -74,13 +76,15 @@ export class Dashboards extends Component { dashboards: Dashboard[], createDashboard: (Dashboard) => any, fetchDashboards: () => void, - setFavorited: (dashboardId: number, favorited: boolean) => void + setFavorited: SetFavoritedAction, + setArchived: SetArchivedAction }; state = { modalOpen: false, searchText: "", - section: SECTIONS[0] + section: SECTIONS[0], + showArchived: false } componentWillMount() { @@ -115,12 +119,13 @@ export class Dashboards extends Component { ); } - /* Returns a boolean indicating whether the search term was found from dashboard name or description */ + archivedFilter = (isArchived : boolean) => + ({archived} : Dashboard) => !!archived === isArchived + searchTextFilter = (searchText: string) => ({name, description}: Dashboard) => (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) - /* Returns a boolean indicating whether the dashboard belongs to the specified section or not */ sectionFilter = (section: ListFilterWidgetItem) => ({creator_id, favorite}: Dashboard) => (section.id === SECTION_ID_ALL) || @@ -128,11 +133,12 @@ export class Dashboards extends Component { (section.id === SECTION_ID_FAVORITES && favorite === true) getFilteredDashboards = () => { - const {searchText, section} = this.state; + const {searchText, section, showArchived} = this.state; const {dashboards} = this.props; const noOpFilter = _.constant(true) return _.chain(dashboards) + .filter(this.archivedFilter(showArchived)) .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) .filter(this.sectionFilter(section)) .value() @@ -143,7 +149,7 @@ export class Dashboards extends Component { } render() { - let {modalOpen, searchText, section} = this.state; + let {modalOpen, searchText, section, showArchived} = this.state; const isLoading = this.props.dashboards === null const noDashboardsCreated = this.props.dashboards && this.props.dashboards.length === 0 @@ -205,7 +211,9 @@ export class Dashboards extends Component { smallDescription />
    - : + : }
    diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index b9700f9bf27df..48ba4b792053e 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -1,12 +1,17 @@ /* @flow weak */ import { handleActions, createAction, combineReducers, createThunkAction } from "metabase/lib/redux"; -import { DashboardApi } from "metabase/services"; import MetabaseAnalytics from "metabase/lib/analytics"; -import moment from 'moment'; - +import { inflect } from "metabase/lib/formatting"; import * as Urls from "metabase/lib/urls"; +import { DashboardApi } from "metabase/services"; +import { addUndo } from "metabase/redux/undo"; + +import React from "react"; +import {Link} from "react-router"; import { push } from "react-router-redux"; +import moment from 'moment'; + import type { Dashboard } from "metabase/meta/types/Dashboard"; export const FETCH_DASHBOARDS = "metabase/dashboards/FETCH_DASHBOARDS"; @@ -15,6 +20,7 @@ export const DELETE_DASHBOARD = "metabase/dashboards/DELETE_DASHBOARD"; export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; export const UPDATE_DASHBOARD = "metabase/dashboards/UPDATE_DASHBOARD"; export const SET_FAVORITED = "metabase/dashboards/SET_FAVORITED"; +export const SET_ARCHIVED = "metabase/dashboards/SET_ARCHIVED"; /** * Actions that retrieve/update the basic information of dashboards @@ -93,7 +99,8 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashboar }; }); -export const setFavorited = createThunkAction(SET_FAVORITED, (dashId, favorited) => { +export type SetFavoritedAction = (dashId: number, favorited: boolean) => void; +export const setFavorited: SetFavoritedAction = createThunkAction(SET_FAVORITED, (dashId, favorited) => { return async (dispatch, getState) => { if (favorited) { // await DashboardApi.favorite({ dashId }); @@ -105,13 +112,47 @@ export const setFavorited = createThunkAction(SET_FAVORITED, (dashId, favorited) } }); +// A simplified version of a similar method in questions/questions.js +function createUndo(type, action) { + return { + type: type, + count: 1, + message: (undo) => // eslint-disable-line react/display-name +
    { "Dashboard was " + type + "."}
    , + actions: [action] + }; +} + +export type SetArchivedAction = (dashId: number, archived: boolean, undoable: boolean) => void; +export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, undoable = false) => { + return async (dispatch, getState) => { + // TODO Remove mock + /*const response = await DashboardApi.update({ + id: dashId, + archived: archived + });*/ + const response = {id: dashId, archived: archived} + + if (undoable) { + dispatch(addUndo(createUndo( + archived ? "archived" : "unarchived", + setArchived(dashId, !archived) + ))); + } + + MetabaseAnalytics.trackEvent("Dashboard", archived ? "Archive" : "Unarchive"); + return response; + } +}); + const dashboardListing = handleActions({ [FETCH_DASHBOARDS]: (state, { payload }) => payload, [CREATE_DASHBOARD]: (state, { payload }) => (state || []).concat(payload), [DELETE_DASHBOARD]: (state, { payload }) => (state || []).filter(d => d.id !== payload), [SAVE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), [UPDATE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), - [SET_FAVORITED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, favorite: payload.favorite} : d) + [SET_FAVORITED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, favorite: payload.favorite} : d), + [SET_ARCHIVED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, archived: payload.archived} : d) }, null); export default combineReducers({ diff --git a/frontend/src/metabase/meta/types/Dashboard.js b/frontend/src/metabase/meta/types/Dashboard.js index 6c71b75770f37..45c6fa7db39b8 100644 --- a/frontend/src/metabase/meta/types/Dashboard.js +++ b/frontend/src/metabase/meta/types/Dashboard.js @@ -10,6 +10,7 @@ export type Dashboard = { id: DashboardId, name: string, favorite: boolean, + archived: boolean, created_at: ?string, creator_id: number, description: ?string, From 6b7276dd9c90b2eb92a2b701ecfa9fc70d3405bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 17:11:11 -0700 Subject: [PATCH 025/202] Dashboard archive list --- .../dashboards/components/DashboardList.jsx | 2 +- .../dashboards/containers/Dashboards.jsx | 47 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 95ec469394aab..b781783501b06 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -68,7 +68,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, /> } - { (dashboard.favorite || hover) && + { setFavorited && (dashboard.favorite || hover) && - ({archived} : Dashboard) => !!archived === isArchived + archivedFilter = (isArchived: boolean) => + ({archived}: Dashboard) => !!archived === isArchived searchTextFilter = (searchText: string) => ({name, description}: Dashboard) => @@ -128,9 +117,9 @@ export class Dashboards extends Component { sectionFilter = (section: ListFilterWidgetItem) => ({creator_id, favorite}: Dashboard) => - (section.id === SECTION_ID_ALL) || - (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) || - (section.id === SECTION_ID_FAVORITES && favorite === true) + (section.id === SECTION_ID_ALL) || + (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) || + (section.id === SECTION_ID_FAVORITES && favorite === true) getFilteredDashboards = () => { const {searchText, section, showArchived} = this.state; @@ -175,25 +164,41 @@ export class Dashboards extends Component {
    :
    - -
    + { showArchived ? + this.setState({showArchived: !showArchived})}/> + : + } + + {!showArchived && +
    + this.setState({showArchived: !showArchived})}/> +
    + }
    this.setState({searchText: text})} /> + {!showArchived &&
    item.id !== "archived")} activeItem={section} onChange={this.updateSection} /> -
    +
    }
    { noSearchResults ?
    @@ -212,7 +217,7 @@ export class Dashboards extends Component { />
    : }
    From 4df781f30e45a3285d880845dd81375475e7ebef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 20:26:54 -0700 Subject: [PATCH 026/202] Add missing :archived clause to dashboards-list --- src/metabase/api/dashboard.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 59aae149965f2..1e891f655ee0b 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -33,7 +33,7 @@ (defn- dashboards-list [filter-option] (as-> (db/select Dashboard {:where [:and (case (or (keyword filter-option) :all) - :all true + (:all :archived) true :mine [:= :creator_id api/*current-user-id*]) [:= :archived (= (keyword filter-option) :archived)]] :order-by [:%lower.name]}) <> From ec43735cb79e104ba7722cb5038dabe2d8fd4550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Tue, 25 Apr 2017 21:14:31 -0700 Subject: [PATCH 027/202] Use real API, create a separate archive list page --- .../dashboard/components/DashboardHeader.jsx | 2 +- .../dashboards/components/DashboardList.jsx | 26 ++-- .../dashboards/containers/Dashboards.jsx | 37 ++---- .../containers/DashboardsArchive.jsx | 124 ++++++++++++++++++ .../src/metabase/dashboards/dashboards.js | 37 ++++-- frontend/src/metabase/dashboards/selectors.js | 1 + .../src/metabase/nav/containers/Navbar.jsx | 2 +- frontend/src/metabase/routes.jsx | 4 +- .../test/e2e/dashboards/dashboards.spec.js | 2 +- .../test/e2e/dashboards/dashboards.utils.js | 2 +- 10 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index 05b46392dd980..6b75ef579d0e1 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -125,7 +125,7 @@ export default class DashboardHeader extends Component<*, Props, State> { async onDelete() { await this.props.deleteDashboard(this.props.dashboard.id); - this.props.onChangeLocation("/dashboard"); + this.props.onChangeLocation("/dashboards"); } // 1. fetch revisions diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index b781783501b06..4abaa5e4498a2 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -19,13 +19,16 @@ type DashboardListItemType = { hover: boolean, setHover: (boolean) => void, setFavorited: (dashId: number, favorited: boolean) => void, - setArchived: (dashId: number, archived: boolean) => void + setArchived: (dashId: number, archived: boolean) => void, + disableLink: boolean } const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover}: DashboardListItemType) => -
  • - { + const WrapperType = disableLink ? 'div' : Link + + return (
  • + setHover(true)} + onMouseEnter={() => !disableLink && setHover(true)} onMouseLeave={() => setHover(false)}> @@ -58,7 +61,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, { (dashboard.archived || hover) && { @@ -88,9 +91,9 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover,
  • - - -); + + ) +}); export default class DashboardList extends Component { static propTypes = { @@ -98,14 +101,15 @@ export default class DashboardList extends Component { }; render() { - const {dashboards, setFavorited, setArchived} = this.props; + const {dashboards, disableLinks, setFavorited, setArchived} = this.props; return (
      { dashboards.map(dash => + setArchived={setArchived} + disableLink={disableLinks}/> )}
    ); diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 13bf177c6ac24..e8ffbba7657d4 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -2,6 +2,7 @@ import React, {Component} from 'react'; import {connect} from "react-redux"; +import {Link} from "react-router"; import cx from "classnames"; import _ from "underscore" @@ -9,7 +10,6 @@ import type {Dashboard} from "metabase/meta/types/Dashboard"; import DashboardList from "../components/DashboardList"; -import HeaderWithBack from "../../components/HeaderWithBack"; import TitleAndDescription from "metabase/components/TitleAndDescription"; import CreateDashboardModal from "metabase/components/CreateDashboardModal"; import Modal from "metabase/components/Modal.jsx"; @@ -72,8 +72,7 @@ export class Dashboards extends Component { state = { modalOpen: false, searchText: "", - section: SECTIONS[0], - showArchived: false + section: SECTIONS[0] } componentWillMount() { @@ -108,9 +107,6 @@ export class Dashboards extends Component { ); } - archivedFilter = (isArchived: boolean) => - ({archived}: Dashboard) => !!archived === isArchived - searchTextFilter = (searchText: string) => ({name, description}: Dashboard) => (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) @@ -122,12 +118,11 @@ export class Dashboards extends Component { (section.id === SECTION_ID_FAVORITES && favorite === true) getFilteredDashboards = () => { - const {searchText, section, showArchived} = this.state; + const {searchText, section} = this.state; const {dashboards} = this.props; const noOpFilter = _.constant(true) return _.chain(dashboards) - .filter(this.archivedFilter(showArchived)) .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) .filter(this.sectionFilter(section)) .value() @@ -138,7 +133,7 @@ export class Dashboards extends Component { } render() { - let {modalOpen, searchText, section, showArchived} = this.state; + let {modalOpen, searchText, section} = this.state; const isLoading = this.props.dashboards === null const noDashboardsCreated = this.props.dashboards && this.props.dashboards.length === 0 @@ -164,19 +159,15 @@ export class Dashboards extends Component { :
    - { showArchived ? - this.setState({showArchived: !showArchived})}/> - : - } + - {!showArchived &&
    - this.setState({showArchived: !showArchived})}/> + + +
    - }
    this.setState({searchText: text})} /> - {!showArchived &&
    item.id !== "archived")} activeItem={section} onChange={this.updateSection} /> -
    } +
    { noSearchResults ?
    @@ -217,7 +206,7 @@ export class Dashboards extends Component { />
    : } diff --git a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx new file mode 100644 index 0000000000000..0f58001965139 --- /dev/null +++ b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx @@ -0,0 +1,124 @@ +/* @flow */ + +import React, {Component} from 'react'; +import {connect} from "react-redux"; +import cx from "classnames"; +import _ from "underscore" + +import type {Dashboard} from "metabase/meta/types/Dashboard"; + +import DashboardList from "../components/DashboardList"; + +import HeaderWithBack from "../../components/HeaderWithBack"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import SearchHeader from "metabase/components/SearchHeader"; +import EmptyState from "metabase/components/EmptyState"; +import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; + +import {caseInsensitiveSearch} from "metabase/lib/string" + +import type {SetArchivedAction} from "../dashboards"; +import {fetchArchivedDashboards, setArchived} from "../dashboards"; +import {getArchivedDashboards} from "../selectors"; + +const mapStateToProps = (state, props) => ({ + dashboards: getArchivedDashboards(state) +}); + +const mapDispatchToProps = {fetchArchivedDashboards, setArchived}; + +export class Dashboards extends Component { + props: { + dashboards: Dashboard[], + fetchArchivedDashboards: () => void, + setArchived: SetArchivedAction + }; + + state = { + searchText: "", + } + componentWillMount() { + this.props.fetchArchivedDashboards(); + } + + searchTextFilter = (searchText: string) => + ({name, description}: Dashboard) => + (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) + + getFilteredDashboards = () => { + const {searchText} = this.state; + const {dashboards} = this.props; + const noOpFilter = _.constant(true) + + return _.chain(dashboards) + .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) + .value() + } + + updateSection = (section: ListFilterWidgetItem) => { + this.setState({section}); + } + + render() { + let {searchText} = this.state; + + const isLoading = this.props.dashboards === null + const noDashboardsArchived = this.props.dashboards && this.props.dashboards.length === 0 + const filteredDashboards = isLoading ? [] : this.getFilteredDashboards(); + const noSearchResults = searchText !== "" && filteredDashboards.length === 0; + + return ( + + { noDashboardsArchived ? +
    + You haven't archived any dashboards yet.} + image="/app/img/dashboard_illustration" + action="Create a dashboard" + onActionClick={this.showCreateDashboard} + className="mt2" + imageClassName="mln2" + /> +
    + :
    +
    + +
    +
    + this.setState({searchText: text})} + /> +
    + { noSearchResults ? +
    + +

    No results found

    +

    Try adjusting your filter to find what you’re + looking for.

    +
    + } + image="/app/img/empty_dashboard" + action="Create a dashboard" + imageClassName="mln2" + smallDescription + /> +
    + : + } + + + } +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Dashboards) diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index 48ba4b792053e..9323bbf2f0fab 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -15,6 +15,7 @@ import moment from 'moment'; import type { Dashboard } from "metabase/meta/types/Dashboard"; export const FETCH_DASHBOARDS = "metabase/dashboards/FETCH_DASHBOARDS"; +export const FETCH_ARCHIVE = "metabase/dashboards/FETCH_ARCHIVE"; export const CREATE_DASHBOARD = "metabase/dashboards/CREATE_DASHBOARD"; export const DELETE_DASHBOARD = "metabase/dashboards/DELETE_DASHBOARD"; export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; @@ -39,6 +40,18 @@ export const fetchDashboards = createThunkAction(FETCH_DASHBOARDS, () => } ); +export const fetchArchivedDashboards = createThunkAction(FETCH_ARCHIVE, () => + async function(dispatch, getState) { + const dashboards = await DashboardApi.list({f: "archived"}) + + for (const dashboard of dashboards) { + dashboard.updated_at = moment(dashboard.updated_at); + } + + return dashboards; + } +); + type CreateDashboardOpts = { redirect?: boolean } @@ -103,9 +116,9 @@ export type SetFavoritedAction = (dashId: number, favorited: boolean) => void; export const setFavorited: SetFavoritedAction = createThunkAction(SET_FAVORITED, (dashId, favorited) => { return async (dispatch, getState) => { if (favorited) { - // await DashboardApi.favorite({ dashId }); + await DashboardApi.favorite({ dashId }); } else { - // await DashboardApi.unfavorite({ dashId }); + await DashboardApi.unfavorite({ dashId }); } MetabaseAnalytics.trackEvent("Dashboard", favorited ? "Favorite" : "Unfavorite"); return { id: dashId, favorite: favorited }; @@ -126,12 +139,10 @@ function createUndo(type, action) { export type SetArchivedAction = (dashId: number, archived: boolean, undoable: boolean) => void; export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, undoable = false) => { return async (dispatch, getState) => { - // TODO Remove mock - /*const response = await DashboardApi.update({ + const response = await DashboardApi.update({ id: dashId, archived: archived - });*/ - const response = {id: dashId, archived: archived} + }); if (undoable) { dispatch(addUndo(createUndo( @@ -145,6 +156,13 @@ export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, un } }); +const archive = handleActions({ + [FETCH_ARCHIVE]: (state, { payload }) => payload, + [SET_ARCHIVED]: (state, {payload}) => payload.archived + ? (state || []).concat(payload) + : (state || []).filter(d => d.id !== payload.id) +}, null); + const dashboardListing = handleActions({ [FETCH_DASHBOARDS]: (state, { payload }) => payload, [CREATE_DASHBOARD]: (state, { payload }) => (state || []).concat(payload), @@ -152,10 +170,13 @@ const dashboardListing = handleActions({ [SAVE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), [UPDATE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), [SET_FAVORITED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, favorite: payload.favorite} : d), - [SET_ARCHIVED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, archived: payload.archived} : d) + [SET_ARCHIVED]: (state, {payload}) => payload.archived + ? (state || []).filter(d => d.id !== payload.id) + : (state || []).concat(payload) }, null); export default combineReducers({ - dashboardListing + dashboardListing, + archive }); diff --git a/frontend/src/metabase/dashboards/selectors.js b/frontend/src/metabase/dashboards/selectors.js index 28affff034248..e49de25414e69 100644 --- a/frontend/src/metabase/dashboards/selectors.js +++ b/frontend/src/metabase/dashboards/selectors.js @@ -1 +1,2 @@ export const getDashboardListing = (state) => state.dashboards.dashboardListing; +export const getArchivedDashboards = (state) => state.dashboards.archive; diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 843f3fe691325..81a395e7187a7 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -126,7 +126,7 @@ export default class Navbar extends Component {
  • - +
  • diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 915aafe584cc0..3f227810f1a29 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -21,6 +21,7 @@ import GoogleNoAccount from "metabase/auth/components/GoogleNoAccount.jsx"; // main app containers import HomepageApp from "metabase/home/containers/HomepageApp.jsx"; import Dashboards from "metabase/dashboards/containers/Dashboards.jsx"; +import DashboardsArchive from "metabase/dashboards/containers/DashboardsArchive.jsx"; import DashboardApp from "metabase/dashboard/containers/DashboardApp.jsx"; import QuestionIndex from "metabase/questions/containers/QuestionIndex.jsx"; @@ -153,7 +154,8 @@ export const getRoutes = (store) => {/* DASHBOARD LIST */} - + + {/* INDIVIDUAL DASHBOARDS */} diff --git a/frontend/test/e2e/dashboards/dashboards.spec.js b/frontend/test/e2e/dashboards/dashboards.spec.js index fac8f81d081a9..06d7fed688d3c 100644 --- a/frontend/test/e2e/dashboards/dashboards.spec.js +++ b/frontend/test/e2e/dashboards/dashboards.spec.js @@ -18,7 +18,7 @@ describeE2E("dashboards/dashboards", () => { }); it("should let you create new dashboards, see them, filter them and enter them", async () => { - await d.get("/dashboard"); + await d.get("/dashboards"); await d.screenshot("screenshots/dashboards.png"); await createDashboardInEmptyState(); diff --git a/frontend/test/e2e/dashboards/dashboards.utils.js b/frontend/test/e2e/dashboards/dashboards.utils.js index b19c207236572..ab790367caad8 100644 --- a/frontend/test/e2e/dashboards/dashboards.utils.js +++ b/frontend/test/e2e/dashboards/dashboards.utils.js @@ -11,7 +11,7 @@ export const getPreviousDashboardUrl = (nFromLatest) => { } export const createDashboardInEmptyState = async () => { - await d.get("/dashboard"); + await d.get("/dashboards"); // Create a new dashboard in the empty state (EmptyState react component) await d.select(".Button.Button--primary").wait().click(); From e81546e3fbb8c0fdeaf2fd184d6730a915e7ec8a Mon Sep 17 00:00:00 2001 From: Kyle Doherty Date: Wed, 26 Apr 2017 15:22:40 -0700 Subject: [PATCH 028/202] sizing and nightmode for public dashboards --- .../src/metabase/public/containers/PublicDashboard.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/metabase/public/containers/PublicDashboard.jsx b/frontend/src/metabase/public/containers/PublicDashboard.jsx index c74d4efe4133b..ce1c673cc9f2a 100644 --- a/frontend/src/metabase/public/containers/PublicDashboard.jsx +++ b/frontend/src/metabase/public/containers/PublicDashboard.jsx @@ -3,6 +3,7 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; +import cx from 'classnames'; import { IFRAMED } from "metabase/lib/dom"; @@ -52,6 +53,8 @@ type Props = { parameterValues: {[key:string]: string}, initialize: () => void, + isFullscreen: boolean, + isNightMode: boolean, fetchDashboard: (dashId: string, query: { [key:string]: string }) => Promise, fetchDashboardCardData: (options: { reload: bool, clear: bool }) => Promise, setParameterValue: (id: string, value: string) => void, @@ -81,7 +84,7 @@ export default class PublicDashboard extends Component<*, Props, *> { } render() { - const { dashboard, parameters, parameterValues } = this.props; + const { dashboard, parameters, parameterValues, isFullscreen, isNightMode } = this.props; const buttons = !IFRAMED ? getDashboardActions(this.props) : []; return ( @@ -99,7 +102,7 @@ export default class PublicDashboard extends Component<*, Props, *> { } > - + { () => Date: Wed, 26 Apr 2017 16:56:41 -0700 Subject: [PATCH 029/202] Fix the margin between archive/favourites buttons and ellipsified title --- frontend/src/metabase/dashboards/components/DashboardList.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 4abaa5e4498a2..34ef97bc91f4a 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -61,7 +61,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, { (dashboard.archived || hover) && { @@ -75,7 +75,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, Date: Wed, 26 Apr 2017 16:58:18 -0700 Subject: [PATCH 030/202] Use dashboard icon for 'All dashboards' filter item --- frontend/src/metabase/dashboards/containers/Dashboards.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index e8ffbba7657d4..15c822f6d223c 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -43,7 +43,7 @@ const SECTIONS: ListFilterWidgetItem[] = [ { id: SECTION_ID_ALL, name: 'All dashboards', - icon: 'all', + icon: 'dashboard', // empty: 'No questions have been saved yet.', }, { From 8f9479509b7443e8cfd306c236c6852bcafea809 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Wed, 26 Apr 2017 18:04:10 -0700 Subject: [PATCH 031/202] Finish new drill action menu + icons --- frontend/src/metabase/icon_paths.js | 13 +++++++ .../qb/components/drill/QuickFilterDrill.jsx | 3 +- ...Drill.js => SummarizeColumnByTimeDrill.js} | 38 +++++++++---------- ....js => SummarizeColumnByTimeDrill.spec.js} | 14 ++++--- .../components/drill/SummarizeColumnDrill.js | 22 +++++------ .../qb/components/modes/SegmentMode.jsx | 4 +- .../qb/components/modes/TimeseriesMode.jsx | 6 +-- .../components/ChartClickActions.jsx | 16 ++++---- 8 files changed, 66 insertions(+), 50 deletions(-) rename frontend/src/metabase/qb/components/drill/{SumColumnByTimeDrill.js => SummarizeColumnByTimeDrill.js} (57%) rename frontend/src/metabase/qb/components/drill/{SumColumnByTimeDrill.spec.js => SummarizeColumnByTimeDrill.spec.js} (72%) diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 32946e7dc208d..60dc88e31eb1e 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -19,6 +19,7 @@ export var ICON_PATHS = { backArrow: 'M11.7416687,19.0096 L18.8461178,26.4181004 L14.2696969,30.568 L0.38960831,16.093881 L0,15.6875985 L0.49145276,15.241949 L14.6347557,1 L19.136,5.22693467 L11.3214393,13.096 L32,13.096 L32,19.0096 L11.7416687,19.0096 Z', bar: 'M2 23.467h6.4V32H2v-8.533zm10.667-12.8h6.4V32h-6.4V10.667zM23.333 0h6.4v32h-6.4V0z', beaker: 'M4.31736354,31.1631075 C3.93810558,30.6054137 3.89343681,29.6635358 4.20559962,29.0817181 L11.806982,14.9140486 L11.8069821,10.5816524 L10.7015144,10.4653256 C10.0309495,10.394763 9.48734928,9.78799739 9.48734928,9.12166999 L9.48734928,7.34972895 C9.48734928,6.67821106 10.0368737,6.13383825 10.7172248,6.13383825 L21.8462005,6.13383825 C22.525442,6.13383825 23.0760761,6.68340155 23.0760761,7.34972895 L23.0760761,9.12166999 C23.0760761,9.79318788 22.5250158,10.3375607 21.856025,10.3375607 L20.9787023,10.3375607 L20.9787024,14.9281806 L28.77277,29.0827118 C29.0983515,29.6739888 29.0709073,30.6193105 28.7174156,31.1846409 L28.852457,30.9686726 C28.4963041,31.538259 27.6541076,32 26.9865771,32 L6.10749779,32 C5.43315365,32 4.58248747,31.5529687 4.19978245,30.9902061 L4.31736354,31.1631075 Z M15.5771418,17.6040443 C16.5170398,17.6040443 17.2789777,16.8377777 17.2789777,15.89254 C17.2789777,14.9473023 16.5170398,14.1810358 15.5771418,14.1810358 C14.6372438,14.1810358 13.8753059,14.9473023 13.8753059,15.89254 C13.8753059,16.8377777 14.6372438,17.6040443 15.5771418,17.6040443 Z M16.5496195,12.8974079 C17.8587633,12.8974079 18.9200339,11.830108 18.9200339,10.5135268 C18.9200339,9.1969457 17.8587633,8.1296458 16.5496195,8.1296458 C15.2404758,8.1296458 14.1792052,9.1969457 14.1792052,10.5135268 C14.1792052,11.830108 15.2404758,12.8974079 16.5496195,12.8974079 Z M5.71098553,30.2209651 L10.9595331,20.5151267 C10.9595331,20.5151267 12.6834557,21.2672852 14.3734184,21.2672852 C16.0633811,21.2672852 16.8198616,19.2872624 17.588452,18.6901539 C18.3570425,18.0930453 19.9467191,17.1113296 19.9467191,17.1113296 L27.0506095,30.1110325 L5.71098553,30.2209651 Z M13.6608671,4.37817079 C14.4114211,4.37817079 15.0198654,3.78121712 15.0198654,3.04483745 C15.0198654,2.30845779 14.4114211,1.71150412 13.6608671,1.71150412 C12.9103132,1.71150412 12.3018689,2.30845779 12.3018689,3.04483745 C12.3018689,3.78121712 12.9103132,4.37817079 13.6608671,4.37817079 Z M17.9214578,2.45333328 C18.6119674,2.45333328 19.1717361,1.90413592 19.1717361,1.22666664 C19.1717361,0.549197362 18.6119674,0 17.9214578,0 C17.2309481,0 16.6711794,0.549197362 16.6711794,1.22666664 C16.6711794,1.90413592 17.2309481,2.45333328 17.9214578,2.45333328 Z', + breakout: 'M.025 12.864s7.682-.008 10.09-.008c2.407 0 6.815 1.072 9.87 3.155 3.055 2.084 6.63 6.03 6.63 6.03l3.22-3.146L32 32l-13.279-2.058 3.31-3.38c-1.263-1.328-4.24-4.964-7.857-6.441-3.617-1.478-10.762-.75-14.15-.98-.055-.003 0-6.277 0-6.277zM31.979 0l-2.123 13.164-3.754-3.62-4.74 4.491c-1.31-1.176-5.238-2.634-5.238-2.634 2.62-1.366 6.32-5.581 6.32-5.581l-3.7-3.824L31.98 0z', bubble: 'M18.155 20.882c-5.178-.638-9.187-5.051-9.187-10.402C8.968 4.692 13.66 0 19.448 0c5.789 0 10.48 4.692 10.48 10.48 0 3.05-1.302 5.797-3.383 7.712a7.127 7.127 0 1 1-8.39 2.69zm-6.392 10.14a2.795 2.795 0 1 1 0-5.59 2.795 2.795 0 0 1 0 5.59zm-6.079-6.288a4.541 4.541 0 1 1 0-9.083 4.541 4.541 0 0 1 0 9.083z', cards: 'M16.5,11 C16.1340991,11 15.7865579,10.9213927 15.4733425,10.7801443 L7.35245972,21.8211652 C7.7548404,22.264891 8,22.8538155 8,23.5 C8,24.8807119 6.88071187,26 5.5,26 C4.11928813,26 3,24.8807119 3,23.5 C3,22.1192881 4.11928813,21 5.5,21 C5.87370843,21 6.22826528,21.0819977 6.5466604,21.2289829 L14.6623495,10.1950233 C14.2511829,9.74948188 14,9.15407439 14,8.5 C14,7.11928813 15.1192881,6 16.5,6 C17.8807119,6 19,7.11928813 19,8.5 C19,8.96980737 18.8704088,9.4093471 18.6450228,9.78482291 L25.0405495,15.4699905 C25.4512188,15.1742245 25.9552632,15 26.5,15 C27.8807119,15 29,16.1192881 29,17.5 C29,18.8807119 27.8807119,20 26.5,20 C25.1192881,20 24,18.8807119 24,17.5 C24,17.0256697 24.1320984,16.5821926 24.3615134,16.2043506 L17.9697647,10.5225413 C17.5572341,10.8228405 17.0493059,11 16.5,11 Z M5.5,25 C6.32842712,25 7,24.3284271 7,23.5 C7,22.6715729 6.32842712,22 5.5,22 C4.67157288,22 4,22.6715729 4,23.5 C4,24.3284271 4.67157288,25 5.5,25 Z M26.5,19 C27.3284271,19 28,18.3284271 28,17.5 C28,16.6715729 27.3284271,16 26.5,16 C25.6715729,16 25,16.6715729 25,17.5 C25,18.3284271 25.6715729,19 26.5,19 Z M16.5,10 C17.3284271,10 18,9.32842712 18,8.5 C18,7.67157288 17.3284271,7 16.5,7 C15.6715729,7 15,7.67157288 15,8.5 C15,9.32842712 15.6715729,10 16.5,10 Z', calendar: { @@ -55,6 +56,7 @@ export var ICON_PATHS = { database: 'M1.18285296e-08,10.5127919 C-1.47856568e-08,7.95412848 1.18285298e-08,4.57337284 1.18285298e-08,4.57337284 C1.18285298e-08,4.57337284 1.58371041,5.75351864e-10 15.6571342,0 C29.730558,-5.7535027e-10 31.8900148,4.13849684 31.8900148,4.57337284 L31.8900148,10.4843058 C31.8900148,10.4843058 30.4448001,15.1365942 16.4659751,15.1365944 C2.48715012,15.1365947 2.14244494e-08,11.4353349 1.18285296e-08,10.5127919 Z M0.305419478,21.1290071 C0.305419478,21.1290071 0.0405133833,21.2033291 0.0405133833,21.8492606 L0.0405133833,27.3032816 C0.0405133833,27.3032816 1.46515486,31.941655 15.9641228,31.941655 C30.4630908,31.941655 32,27.3446712 32,27.3446712 C32,27.3446712 32,21.7986104 32,21.7986105 C32,21.2073557 31.6620557,21.0987647 31.6620557,21.0987647 C31.6620557,21.0987647 29.7146434,25.22314 16.0318829,25.22314 C2.34912233,25.22314 0.305419478,21.1290071 0.305419478,21.1290071 Z M0.305419478,12.656577 C0.305419478,12.656577 0.0405133833,12.730899 0.0405133833,13.3768305 L0.0405133833,18.8308514 C0.0405133833,18.8308514 1.46515486,23.4692249 15.9641228,23.4692249 C30.4630908,23.4692249 32,18.8722411 32,18.8722411 C32,18.8722411 32,13.3261803 32,13.3261803 C32,12.7349256 31.6620557,12.6263346 31.6620557,12.6263346 C31.6620557,12.6263346 29.7146434,16.7507099 16.0318829,16.7507099 C2.34912233,16.7507099 0.305419478,12.656577 0.305419478,12.656577 Z', dashboard: 'M32,29 L32,4 L32,0 L0,0 L0,8 L28,8 L28,28 L4,28 L4,8 L0,8 L0,29.5 L0,32 L32,32 L32,29 Z M7.27272727,18.9090909 L17.4545455,18.9090909 L17.4545455,23.2727273 L7.27272727,23.2727273 L7.27272727,18.9090909 Z M7.27272727,12.0909091 L24.7272727,12.0909091 L24.7272727,16.4545455 L7.27272727,16.4545455 L7.27272727,12.0909091 Z M20.3636364,18.9090909 L24.7272727,18.9090909 L24.7272727,23.2727273 L20.3636364,23.2727273 L20.3636364,18.9090909 Z', dashboards: 'M17,5.49100518 L17,10.5089948 C17,10.7801695 17.2276528,11 17.5096495,11 L26.4903505,11 C26.7718221,11 27,10.7721195 27,10.5089948 L27,5.49100518 C27,5.21983051 26.7723472,5 26.4903505,5 L17.5096495,5 C17.2281779,5 17,5.22788048 17,5.49100518 Z M18.5017326,14 C18.225722,14 18,13.77328 18,13.4982674 L18,26.5017326 C18,26.225722 18.22672,26 18.5017326,26 L5.49826741,26 C5.77427798,26 6,26.22672 6,26.5017326 L6,13.4982674 C6,13.774278 5.77327997,14 5.49826741,14 L18.5017326,14 Z M14.4903505,6 C14.2278953,6 14,5.78028538 14,5.49100518 L14,10.5089948 C14,10.2167107 14.2224208,10 14.4903505,10 L5.50964952,10 C5.77210473,10 6,10.2197146 6,10.5089948 L6,5.49100518 C6,5.78328929 5.77757924,6 5.50964952,6 L14.4903505,6 Z M26.5089948,22 C26.2251201,22 26,21.7774008 26,21.4910052 L26,26.5089948 C26,26.2251201 26.2225992,26 26.5089948,26 L21.4910052,26 C21.7748799,26 22,26.2225992 22,26.5089948 L22,21.4910052 C22,21.7748799 21.7774008,22 21.4910052,22 L26.5089948,22 Z M26.5089948,14 C26.2251201,14 26,13.7774008 26,13.4910052 L26,18.5089948 C26,18.2251201 26.2225992,18 26.5089948,18 L21.4910052,18 C21.7748799,18 22,18.2225992 22,18.5089948 L22,13.4910052 C22,13.7748799 21.7774008,14 21.4910052,14 L26.5089948,14 Z M26.4903505,6 C26.2278953,6 26,5.78028538 26,5.49100518 L26,10.5089948 C26,10.2167107 26.2224208,10 26.4903505,10 L17.5096495,10 C17.7721047,10 18,10.2197146 18,10.5089948 L18,5.49100518 C18,5.78328929 17.7775792,6 17.5096495,6 L26.4903505,6 Z M5,13.4982674 L5,26.5017326 C5,26.7769181 5.21990657,27 5.49826741,27 L18.5017326,27 C18.7769181,27 19,26.7800934 19,26.5017326 L19,13.4982674 C19,13.2230819 18.7800934,13 18.5017326,13 L5.49826741,13 C5.22308192,13 5,13.2199066 5,13.4982674 Z M5,5.49100518 L5,10.5089948 C5,10.7801695 5.22765279,11 5.50964952,11 L14.4903505,11 C14.7718221,11 15,10.7721195 15,10.5089948 L15,5.49100518 C15,5.21983051 14.7723472,5 14.4903505,5 L5.50964952,5 C5.22817786,5 5,5.22788048 5,5.49100518 Z M21,21.4910052 L21,26.5089948 C21,26.7801695 21.2278805,27 21.4910052,27 L26.5089948,27 C26.7801695,27 27,26.7721195 27,26.5089948 L27,21.4910052 C27,21.2198305 26.7721195,21 26.5089948,21 L21.4910052,21 C21.2198305,21 21,21.2278805 21,21.4910052 Z M21,13.4910052 L21,18.5089948 C21,18.7801695 21.2278805,19 21.4910052,19 L26.5089948,19 C26.7801695,19 27,18.7721195 27,18.5089948 L27,13.4910052 C27,13.2198305 26.7721195,13 26.5089948,13 L21.4910052,13 C21.2198305,13 21,13.2278805 21,13.4910052 Z', + distribution: 'M1.457 4.731v21.807h29.815a.73.73 0 0 1 .728.73.73.73 0 0 1-.728.732H0V4.731A.73.73 0 0 1 .728 4a.73.73 0 0 1 .729.731zM5.795 21.53a1.824 1.824 0 0 1-1.82-1.828c0-1.01.814-1.828 1.82-1.828 2.094 0 2.965-1.21 4.205-5.255l.147-.484c1.609-5.271 2.99-7.353 6.779-7.353 3.792 0 5.17 2.084 6.765 7.362l.145.48c1.227 4.043 2.093 5.25 4.181 5.25 1.006 0 1.821.819 1.821 1.828 0 1.01-.815 1.828-1.82 1.828-4.305 0-6.005-2.37-7.666-7.841l-.146-.483c-1.14-3.77-1.8-4.769-3.28-4.769-1.483 0-2.147 1-3.297 4.769l-.148.487c-1.675 5.469-3.382 7.837-7.686 7.837z', document: 'M29,10.1052632 L29,28.8325291 C29,30.581875 27.5842615,32 25.8337327,32 L7.16626728,32 C5.41758615,32 4,30.5837102 4,28.8441405 L4,3.15585953 C4,1.41292644 5.42339685,9.39605581e-15 7.15970573,8.42009882e-15 L20.713352,8.01767853e-16 L20.713352,8.42105263 L22.3846872,8.42105263 L22.3846872,0.310375032 L28.7849894,8.42105263 L20.713352,8.42105263 L20.713352,10.1052632 L29,10.1052632 Z M7.3426704,12.8000006 L25.7273576,12.8000006 L25.7273576,14.4842112 L7.3426704,14.4842112 L7.3426704,12.8000006 Z M7.3426704,17.3473687 L25.7273576,17.3473687 L25.7273576,19.0315793 L7.3426704,19.0315793 L7.3426704,17.3473687 Z M7.3426704,21.8947352 L25.7273576,21.8947352 L25.7273576,23.5789458 L7.3426704,23.5789458 L7.3426704,21.8947352 Z M7.43137255,26.2736849 L16.535014,26.2736849 L16.535014,27.9578954 L7.43137255,27.9578954 L7.43137255,26.2736849 Z', downarrow: 'M12.2782161,19.3207547 L12.2782161,0 L19.5564322,0 L19.5564322,19.3207547 L26.8346484,19.3207547 L15.9173242,32 L5,19.3207547 L12.2782161,19.3207547 Z', download: { @@ -92,6 +94,15 @@ export var ICON_PATHS = { }, funnel: 'M3.18586974,3.64621479 C2.93075885,3.28932022 3.08031197,3 3.5066208,3 L28.3780937,3 C28.9190521,3 29.0903676,3.34981042 28.7617813,3.77995708 L18.969764,16.5985181 L18.969764,24.3460671 C18.969764,24.8899179 18.5885804,25.5564176 18.133063,25.8254534 C18.133063,25.8254534 12.5698889,29.1260709 12.5673818,28.9963552 C12.4993555,25.4767507 12.5749031,16.7812673 12.5749031,16.7812673 L3.18586974,3.64621479 Z', funneladd: 'M22.5185184,5.27947653 L17.2510286,5.27947653 L17.2510286,9.50305775 L22.5185184,9.50305775 L22.5185184,14.7825343 L26.7325102,14.7825343 L26.7325102,9.50305775 L32,9.50305775 L32,5.27947653 L26.7325102,5.27947653 L26.7325102,0 L22.5185184,0 L22.5185184,5.27947653 Z M14.9369872,0.791920724 C14.9369872,0.791920724 2.77552871,0.83493892 1.86648164,0.83493892 C0.957434558,0.83493892 0.45215388,1.50534608 0.284450368,1.77831828 C0.116746855,2.05129048 -0.317642562,2.91298361 0.398382661,3.9688628 C1.11440788,5.024742 9.74577378,17.8573356 9.74577378,17.8573356 C9.74577378,17.8573356 9.74577394,28.8183645 9.74577378,29.6867194 C9.74577362,30.5550744 9.83306175,31.1834301 10.7557323,31.6997692 C11.6784029,32.2161084 12.4343349,31.9564284 12.7764933,31.7333621 C13.1186517,31.5102958 19.6904355,27.7639669 20.095528,27.4682772 C20.5006204,27.1725875 20.7969652,26.5522071 20.7969651,25.7441659 C20.7969649,24.9361247 20.7969651,18.2224765 20.7969651,18.2224765 L21.6163131,16.9859755 L18.152048,15.0670739 C18.152048,15.0670739 17.3822517,16.199685 17.2562629,16.4000338 C17.1302741,16.6003826 16.8393552,16.9992676 16.8393551,17.7062886 C16.8393549,18.4133095 16.8393551,24.9049733 16.8393551,24.9049733 L13.7519708,26.8089871 C13.7519708,26.8089871 13.7318369,18.3502323 13.7318367,17.820601 C13.7318366,17.2909696 13.8484216,16.6759061 13.2410236,15.87149 C12.6336257,15.0670739 5.59381579,4.76288686 5.59381579,4.76288686 L14.9359238,4.76288686 L14.9369872,0.791920724 Z', + funneloutline: { + path: 'M3.186 3.646C2.93 3.29 3.08 3 3.506 3h24.872c.541 0 .712.35.384.78L18.97 16.599v7.747c0 .544-.381 1.21-.837 1.48 0 0-5.563 3.3-5.566 3.17-.068-3.52.008-12.215.008-12.215L3.185 3.646z', + attrs: { + stroke: "currentcolor", + strokeWidth: "4", + fill: "none", + fillRule: "evenodd" + } + }, folder: "M3.96901618e-15,5.41206355 L0.00949677904,29 L31.8821132,29 L31.8821132,10.8928571 L18.2224205,10.8928571 L15.0267944,5.41206355 L3.96901618e-15,5.41206355 Z M16.8832349,5.42402804 L16.8832349,4.52140947 C16.8832349,3.68115822 17.5639241,3 18.4024298,3 L27.7543992,3 L30.36417,3 C31.2031259,3 31.8832341,3.67669375 31.8832341,4.51317691 L31.8832341,7.86669975 L31.8832349,8.5999999 L18.793039,8.5999999 L16.8832349,5.42402804 Z", gear: 'M14 0 H18 L19 6 L20.707 6.707 L26 3.293 L28.707 6 L25.293 11.293 L26 13 L32 14 V18 L26 19 L25.293 20.707 L28.707 26 L26 28.707 L20.707 25.293 L19 26 L18 32 L14 32 L13 26 L11.293 25.293 L6 28.707 L3.293 26 L6.707 20.707 L6 19 L0 18 L0 14 L6 13 L6.707 11.293 L3.293 6 L6 3.293 L11.293 6.707 L13 6 L14 0 z M16 10 A6 6 0 0 0 16 22 A6 6 0 0 0 16 10', grabber: 'M0,5 L32,5 L32,9.26666667 L0,9.26666667 L0,5 Z M0,13.5333333 L32,13.5333333 L32,17.8 L0,17.8 L0,13.5333333 Z M0,22.0666667 L32,22.0666667 L32,26.3333333 L0,26.3333333 L0,22.0666667 Z', @@ -150,6 +161,7 @@ export var ICON_PATHS = { path: 'M0 11.996A3.998 3.998 0 0 1 4.004 8h23.992A4 4 0 0 1 32 11.996v8.008A3.998 3.998 0 0 1 27.996 24H4.004A4 4 0 0 1 0 20.004v-8.008zM22 11h3.99A3.008 3.008 0 0 1 29 14v4c0 1.657-1.35 3-3.01 3H22V11z', attrs: { fillRule: 'evenodd' } }, + sum: 'M3 27.41l1.984 4.422L27.895 32l.04-5.33-17.086-.125 8.296-9.457-.08-3.602L11.25 5.33H27.43V0H5.003L3.08 4.51l10.448 10.9z', sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2', question: "M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 L16,32 Z M16,29.0909091 C8.77009055,29.0909091 2.90909091,23.2299095 2.90909091,16 C2.90909091,8.77009055 8.77009055,2.90909091 16,2.90909091 C23.2299095,2.90909091 29.0909091,8.77009055 29.0909091,16 C29.0909091,23.2299095 23.2299095,29.0909091 16,29.0909091 Z M12,9.56020942 C12.2727286,9.34380346 12.5694087,9.1413622 12.8900491,8.95287958 C13.2106896,8.76439696 13.5552807,8.59860455 13.9238329,8.45549738 C14.2923851,8.31239021 14.6885728,8.20069848 15.1124079,8.12041885 C15.5362429,8.04013921 15.9950835,8 16.4889435,8 C17.1818216,8 17.8065083,8.08725916 18.3630221,8.2617801 C18.919536,8.43630105 19.3931184,8.68586225 19.7837838,9.0104712 C20.1744491,9.33508016 20.4748147,9.7260012 20.6848894,10.1832461 C20.8949642,10.6404909 21,11.1483393 21,11.7068063 C21,12.2373499 20.9226052,12.6963331 20.7678133,13.0837696 C20.6130213,13.4712061 20.4176916,13.8080265 20.1818182,14.0942408 C19.9459448,14.3804552 19.6861194,14.6282712 19.4023342,14.8376963 C19.1185489,15.0471215 18.8495099,15.2408368 18.5952088,15.4188482 C18.3409078,15.5968595 18.1197798,15.773123 17.9318182,15.947644 C17.7438566,16.1221649 17.6240789,16.3176254 17.5724816,16.5340314 L17.2628993,18 L14.9189189,18 L14.6756757,16.3141361 C14.6167073,15.9720751 14.653562,15.6736487 14.7862408,15.4188482 C14.9189196,15.1640476 15.1013502,14.9336834 15.3335381,14.7277487 C15.565726,14.521814 15.8255514,14.3263535 16.1130221,14.1413613 C16.4004928,13.9563691 16.6695319,13.7574182 16.9201474,13.5445026 C17.1707629,13.3315871 17.3826773,13.0942421 17.5558968,12.8324607 C17.7291163,12.5706793 17.8157248,12.2582915 17.8157248,11.895288 C17.8157248,11.4764377 17.6701489,11.1431077 17.3789926,10.895288 C17.0878364,10.6474682 16.6879632,10.5235602 16.1793612,10.5235602 C15.7886958,10.5235602 15.462532,10.5619542 15.20086,10.6387435 C14.9391879,10.7155327 14.7143744,10.8010466 14.5264128,10.895288 C14.3384511,10.9895293 14.1744479,11.0750432 14.034398,11.1518325 C13.8943482,11.2286217 13.7543005,11.2670157 13.6142506,11.2670157 C13.2972957,11.2670157 13.0614258,11.1378721 12.9066339,10.8795812 L12,9.56020942 Z M14,22 C14,21.7192968 14.0511359,21.4580909 14.1534091,21.2163743 C14.2556823,20.9746577 14.3958324,20.7641335 14.5738636,20.5847953 C14.7518948,20.4054572 14.96212,20.2631584 15.2045455,20.1578947 C15.4469709,20.0526311 15.7121198,20 16,20 C16.2803044,20 16.5416655,20.0526311 16.7840909,20.1578947 C17.0265164,20.2631584 17.2386355,20.4054572 17.4204545,20.5847953 C17.6022736,20.7641335 17.7443177,20.9746577 17.8465909,21.2163743 C17.9488641,21.4580909 18,21.7192968 18,22 C18,22.2807032 17.9488641,22.5438584 17.8465909,22.7894737 C17.7443177,23.0350889 17.6022736,23.2475625 17.4204545,23.4269006 C17.2386355,23.6062387 17.0265164,23.7465882 16.7840909,23.8479532 C16.5416655,23.9493182 16.2803044,24 16,24 C15.7121198,24 15.4469709,23.9493182 15.2045455,23.8479532 C14.96212,23.7465882 14.7518948,23.6062387 14.5738636,23.4269006 C14.3958324,23.2475625 14.2556823,23.0350889 14.1534091,22.7894737 C14.0511359,22.5438584 14,22.2807032 14,22 Z", return:'M15.3040432,11.8500793 C22.1434689,13.0450349 27.291257,18.2496116 27.291257,24.4890512 C27.291257,25.7084278 27.0946472,26.8882798 26.7272246,28.0064033 L26.7272246,28.0064033 C25.214579,22.4825472 20.8068367,18.2141694 15.3040432,17.0604596 L15.3040432,25.1841972 L4.70874296,14.5888969 L15.3040432,3.99359668 L15.3040432,3.99359668 L15.3040432,11.8500793 Z', @@ -192,6 +204,7 @@ export var ICON_PATHS = { attrs: { fillRule: "evenodd" } }, x: 'm11.271709,16 l-3.19744231e-13,4.728291 l4.728291,0 l16,11.271709 l27.271709,2.39808173e-13 l32,4.728291 l20.728291,16 l31.1615012,26.4332102 l26.4332102,31.1615012 l16,20.728291 l5.56678976,31.1615012 l0.838498756,26.4332102 l11.271709,16 z', + zoom: 'M12.416 12.454V8.37h3.256v4.083h4.07v3.266h-4.07v4.083h-3.256V15.72h-4.07v-3.266h4.07zm10.389 13.28c-5.582 4.178-13.543 3.718-18.632-1.37-5.58-5.581-5.595-14.615-.031-20.179 5.563-5.563 14.597-5.55 20.178.031 5.068 5.068 5.545 12.985 1.422 18.563l5.661 5.661a2.08 2.08 0 0 1 .003 2.949 2.085 2.085 0 0 1-2.95-.003l-5.651-5.652zm-1.486-4.371c3.895-3.895 3.885-10.218-.021-14.125-3.906-3.906-10.23-3.916-14.125-.021-3.894 3.894-3.885 10.218.022 14.124 3.906 3.907 10.23 3.916 14.124.022z', "slack": { img: "/app/img/slack.png" } diff --git a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx index 5002ddc31d935..b2b092623836a 100644 --- a/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx +++ b/frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx @@ -47,6 +47,7 @@ export default ( } else if (isFK(column.special_type)) { return [ { + section: "filter", title: ( View this @@ -65,7 +66,7 @@ export default ( let operators = getFiltersForColumn(column) || []; return operators.map(({ name, operator }) => ({ section: "filter", - title: {name}, + title: {name}, card: () => filter(card, operator, column, value) })); }; diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js similarity index 57% rename from frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js rename to frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js index 215ab6d8c2b80..915024ca04be4 100644 --- a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js @@ -9,6 +9,7 @@ import { } from "metabase/qb/lib/actions"; import * as Card from "metabase/meta/Card"; import { isNumeric, isDate } from "metabase/lib/schema_metadata"; +import { capitalize } from "metabase/lib/formatting"; import type { ClickAction, @@ -34,24 +35,23 @@ export default ( } const { column } = clicked; - return [ - { - title: Sum of {column.display_name} by Time, - card: () => - pivot( - summarize( - card, - ["sum", getFieldClauseFromCol(column)], - tableMetadata - ), - [ - "datetime-field", - getFieldClauseFromCol(dateField), - "as", - "day" - ], + return ["sum", "count"].map(aggregation => ({ + title: {capitalize(aggregation)} by time, + section: "sum", + card: () => + pivot( + summarize( + card, + [aggregation, getFieldClauseFromCol(column)], tableMetadata - ) - } - ]; + ), + [ + "datetime-field", + getFieldClauseFromCol(dateField), + "as", + "day" + ], + tableMetadata + ) + })); }; diff --git a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.spec.js similarity index 72% rename from frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js rename to frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.spec.js index 12ea59e124e5f..4785662d0eada 100644 --- a/frontend/src/metabase/qb/components/drill/SumColumnByTimeDrill.spec.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.spec.js @@ -1,6 +1,6 @@ /* eslint-disable flowtype/require-valid-file-annotation */ -import SumColumnByTimeDrill from "./SumColumnByTimeDrill"; +import SummarizeColumnByTimeDrill from "./SummarizeColumnByTimeDrill"; import { card, @@ -8,13 +8,15 @@ import { clickedFloatHeader } from "../__support__/fixtures"; -describe("SumColumnByTimeDrill", () => { +describe("SummarizeColumnByTimeDrill", () => { it("should not be valid for top level actions", () => { - expect(SumColumnByTimeDrill({ card, tableMetadata })).toHaveLength(0); + expect( + SummarizeColumnByTimeDrill({ card, tableMetadata }) + ).toHaveLength(0); }); it("should not be valid if there is no time field", () => { expect( - SumColumnByTimeDrill({ + SummarizeColumnByTimeDrill({ card, tableMetadata: { fields: [] }, clicked: clickedFloatHeader @@ -22,12 +24,12 @@ describe("SumColumnByTimeDrill", () => { ).toHaveLength(0); }); it("should be return correct new card", () => { - const actions = SumColumnByTimeDrill({ + const actions = SummarizeColumnByTimeDrill({ card, tableMetadata, clicked: clickedFloatHeader }); - expect(actions).toHaveLength(1); + expect(actions).toHaveLength(2); const newCard = actions[0].card(); expect(newCard.dataset_query.query).toEqual({ source_table: 10, diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js index c5073a71704db..289d9287db76e 100644 --- a/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js @@ -13,25 +13,25 @@ import type { const AGGREGATIONS = { sum: { - section: "distribution", - title: "Sum", + section: "sum", + title: "Sum" }, avg: { - section: "aggregation", - title: "Avg", + section: "distribution", + title: "Avg" }, min: { - section: "aggregation", - title: "Min", + section: "distribution", + title: "Min" }, max: { - section: "aggregation", - title: "Max", + section: "distribution", + title: "Max" }, distinct: { - section: "aggregation", - title: "Distincts", - }, + section: "distribution", + title: "Distincts" + } }; export default ( diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx index 1f67a61189476..93eccd58ca0ff 100644 --- a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -8,7 +8,7 @@ import SummarizeBySegmentMetricAction import CommonMetricsAction from "../actions/CommonMetricsAction"; import CountByTimeAction from "../actions/CountByTimeAction"; import SummarizeColumnDrill from "../drill/SummarizeColumnDrill"; -import SumColumnByTimeDrill from "../drill/SumColumnByTimeDrill"; +import SummarizeColumnByTimeDrill from "../drill/SummarizeColumnByTimeDrill"; import CountByColumnDrill from "../drill/CountByColumnDrill"; // import PlotSegmentField from "../actions/PlotSegmentField"; @@ -27,7 +27,7 @@ const SegmentMode: QueryMode = { drills: [ ...DEFAULT_DRILLS, SummarizeColumnDrill, - SumColumnByTimeDrill, + SummarizeColumnByTimeDrill, CountByColumnDrill ] }; diff --git a/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx index 8f77ec576b22f..f570cc148965c 100644 --- a/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx +++ b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx @@ -45,12 +45,12 @@ export const TimeseriesModeFooter = (props: Props) => { const TimeseriesMode: QueryMode = { name: "timeseries", - actions: [...DEFAULT_ACTIONS, PivotByCategoryAction, PivotByLocationAction], + actions: [PivotByCategoryAction, PivotByLocationAction, ...DEFAULT_ACTIONS], drills: [ - ...DEFAULT_DRILLS, TimeseriesPivotDrill, PivotByCategoryDrill, - PivotByLocationDrill + PivotByLocationDrill, + ...DEFAULT_DRILLS ], ModeFooter: TimeseriesModeFooter }; diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index de4b57cb0bec4..ee37734880980 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -12,7 +12,7 @@ import _ from "underscore"; const SECTIONS = { zoom: { - icon: "search" + icon: "zoom" }, details: { icon: "document" @@ -21,16 +21,16 @@ const SECTIONS = { icon: "expand" }, breakout: { - icon: "connections" + icon: "breakout" }, - distribution: { - icon: "number" + sum: { + icon: "sum" }, - aggregation: { - icon: "line" + distribution: { + icon: "distribution" }, filter: { - icon: "funnel" + icon: "funneloutline" }, dashboard: { icon: "dashboard" @@ -114,7 +114,7 @@ export default class ChartClickActions extends Component<*, Props, State> {
    {sections.map(([key, actions]) =>
    -
    +
    { SECTIONS[key] && } From f31fb90da162c07aac05a72d129c1fb8b07e9d8f Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 00:41:07 -0700 Subject: [PATCH 032/202] Fix drill sort icon and spacing --- frontend/src/metabase/icon_paths.js | 1 + .../visualizations/components/ChartClickActions.jsx | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 60dc88e31eb1e..c82a3e973dc9b 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -161,6 +161,7 @@ export var ICON_PATHS = { path: 'M0 11.996A3.998 3.998 0 0 1 4.004 8h23.992A4 4 0 0 1 32 11.996v8.008A3.998 3.998 0 0 1 27.996 24H4.004A4 4 0 0 1 0 20.004v-8.008zM22 11h3.99A3.008 3.008 0 0 1 29 14v4c0 1.657-1.35 3-3.01 3H22V11z', attrs: { fillRule: 'evenodd' } }, + sort: 'M14.615.683c.765-.926 2.002-.93 2.77 0L26.39 11.59c.765.927.419 1.678-.788 1.678H6.398c-1.2 0-1.557-.747-.788-1.678L14.615.683zm2.472 30.774c-.6.727-1.578.721-2.174 0l-9.602-11.63c-.6-.727-.303-1.316.645-1.316h20.088c.956 0 1.24.595.645 1.316l-9.602 11.63z', sum: 'M3 27.41l1.984 4.422L27.895 32l.04-5.33-17.086-.125 8.296-9.457-.08-3.602L11.25 5.33H27.43V0H5.003L3.08 4.51l10.448 10.9z', sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2', question: "M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 L16,32 Z M16,29.0909091 C8.77009055,29.0909091 2.90909091,23.2299095 2.90909091,16 C2.90909091,8.77009055 8.77009055,2.90909091 16,2.90909091 C23.2299095,2.90909091 29.0909091,8.77009055 29.0909091,16 C29.0909091,23.2299095 23.2299095,29.0909091 16,29.0909091 Z M12,9.56020942 C12.2727286,9.34380346 12.5694087,9.1413622 12.8900491,8.95287958 C13.2106896,8.76439696 13.5552807,8.59860455 13.9238329,8.45549738 C14.2923851,8.31239021 14.6885728,8.20069848 15.1124079,8.12041885 C15.5362429,8.04013921 15.9950835,8 16.4889435,8 C17.1818216,8 17.8065083,8.08725916 18.3630221,8.2617801 C18.919536,8.43630105 19.3931184,8.68586225 19.7837838,9.0104712 C20.1744491,9.33508016 20.4748147,9.7260012 20.6848894,10.1832461 C20.8949642,10.6404909 21,11.1483393 21,11.7068063 C21,12.2373499 20.9226052,12.6963331 20.7678133,13.0837696 C20.6130213,13.4712061 20.4176916,13.8080265 20.1818182,14.0942408 C19.9459448,14.3804552 19.6861194,14.6282712 19.4023342,14.8376963 C19.1185489,15.0471215 18.8495099,15.2408368 18.5952088,15.4188482 C18.3409078,15.5968595 18.1197798,15.773123 17.9318182,15.947644 C17.7438566,16.1221649 17.6240789,16.3176254 17.5724816,16.5340314 L17.2628993,18 L14.9189189,18 L14.6756757,16.3141361 C14.6167073,15.9720751 14.653562,15.6736487 14.7862408,15.4188482 C14.9189196,15.1640476 15.1013502,14.9336834 15.3335381,14.7277487 C15.565726,14.521814 15.8255514,14.3263535 16.1130221,14.1413613 C16.4004928,13.9563691 16.6695319,13.7574182 16.9201474,13.5445026 C17.1707629,13.3315871 17.3826773,13.0942421 17.5558968,12.8324607 C17.7291163,12.5706793 17.8157248,12.2582915 17.8157248,11.895288 C17.8157248,11.4764377 17.6701489,11.1431077 17.3789926,10.895288 C17.0878364,10.6474682 16.6879632,10.5235602 16.1793612,10.5235602 C15.7886958,10.5235602 15.462532,10.5619542 15.20086,10.6387435 C14.9391879,10.7155327 14.7143744,10.8010466 14.5264128,10.895288 C14.3384511,10.9895293 14.1744479,11.0750432 14.034398,11.1518325 C13.8943482,11.2286217 13.7543005,11.2670157 13.6142506,11.2670157 C13.2972957,11.2670157 13.0614258,11.1378721 12.9066339,10.8795812 L12,9.56020942 Z M14,22 C14,21.7192968 14.0511359,21.4580909 14.1534091,21.2163743 C14.2556823,20.9746577 14.3958324,20.7641335 14.5738636,20.5847953 C14.7518948,20.4054572 14.96212,20.2631584 15.2045455,20.1578947 C15.4469709,20.0526311 15.7121198,20 16,20 C16.2803044,20 16.5416655,20.0526311 16.7840909,20.1578947 C17.0265164,20.2631584 17.2386355,20.4054572 17.4204545,20.5847953 C17.6022736,20.7641335 17.7443177,20.9746577 17.8465909,21.2163743 C17.9488641,21.4580909 18,21.7192968 18,22 C18,22.2807032 17.9488641,22.5438584 17.8465909,22.7894737 C17.7443177,23.0350889 17.6022736,23.2475625 17.4204545,23.4269006 C17.2386355,23.6062387 17.0265164,23.7465882 16.7840909,23.8479532 C16.5416655,23.9493182 16.2803044,24 16,24 C15.7121198,24 15.4469709,23.9493182 15.2045455,23.8479532 C14.96212,23.7465882 14.7518948,23.6062387 14.5738636,23.4269006 C14.3958324,23.2475625 14.2556823,23.0350889 14.1534091,22.7894737 C14.0511359,22.5438584 14,22.2807032 14,22 Z", diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index ee37734880980..859fbe2f0283f 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -18,7 +18,7 @@ const SECTIONS = { icon: "document" }, sort: { - icon: "expand" + icon: "sort" }, breakout: { icon: "breakout" @@ -114,11 +114,7 @@ export default class ChartClickActions extends Component<*, Props, State> {
    {sections.map(([key, actions]) =>
    -
    - { SECTIONS[key] && - - } -
    + { actions.map((action, index) =>
    Date: Thu, 27 Apr 2017 00:57:39 -0700 Subject: [PATCH 033/202] Fix flow errors --- frontend/src/metabase/visualizations/components/PinMap.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/metabase/visualizations/components/PinMap.jsx b/frontend/src/metabase/visualizations/components/PinMap.jsx index 4ac01ee845ed6..e642709d999b9 100644 --- a/frontend/src/metabase/visualizations/components/PinMap.jsx +++ b/frontend/src/metabase/visualizations/components/PinMap.jsx @@ -23,6 +23,7 @@ type State = { zoom: ?number, points: L.Point[], bounds: L.Bounds, + filtering: boolean, }; const MAP_COMPONENTS_BY_TYPE = { @@ -44,6 +45,7 @@ export default class PinMap extends Component<*, Props, State> { } state: State; + _map: ?(LeafletMarkerPinMap|LeafletTilePinMap) = null; constructor(props: Props) { super(props); @@ -51,6 +53,7 @@ export default class PinMap extends Component<*, Props, State> { lat: null, lng: null, zoom: null, + filtering: false, ...this._getPoints(props) }; } From 4b0e997e50f42784918095ff020e0e0b3c56b8ea Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 02:15:39 -0700 Subject: [PATCH 034/202] Lint fix --- frontend/src/metabase/query_builder/actions.js | 1 - frontend/src/metabase/redux/metadata.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 6d0737eee5cf3..0c112e73d730f 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -13,7 +13,6 @@ import MetabaseAnalytics from "metabase/lib/analytics"; import { loadCard, isCardDirty, startNewCard, deserializeCardFromUrl, serializeCardForUrl, cleanCopyCard, urlForCardState } from "metabase/lib/card"; import { formatSQL, humanize } from "metabase/lib/formatting"; import Query, { createQuery } from "metabase/lib/query"; -import { loadTableAndForeignKeys } from "metabase/lib/table"; import { isPK, isFK } from "metabase/lib/types"; import Utils from "metabase/lib/utils"; import { getEngineNativeType, formatJsonQuery } from "metabase/lib/engine"; diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 33bdfa0e22d25..203ac6f4c4cdf 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -10,7 +10,7 @@ import { import { normalize } from "normalizr"; import { DatabaseSchema, TableSchema, FieldSchema, SegmentSchema, MetricSchema } from "metabase/schema"; -import { getIn, assoc, assocIn } from "icepick"; +import { getIn, assocIn } from "icepick"; import _ from "underscore"; import { MetabaseApi, MetricApi, SegmentApi, RevisionsApi } from "metabase/services"; From 275bdf1ce967a4203dfc2a05054a35987426aefd Mon Sep 17 00:00:00 2001 From: Kyle Doherty Date: Thu, 27 Apr 2017 10:03:20 -0600 Subject: [PATCH 035/202] remove fullscreen dimming logic (#4877) --- .../metabase/dashboard/components/DashCard.jsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index 9a0c33b9382f5..b94dced8e878a 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -38,9 +38,6 @@ export default class DashCard extends Component { async componentDidMount() { const { dashcard, markNewCardSeen } = this.props; - this.visibilityTimer = window.setInterval(this.updateVisibility, 2000); - window.addEventListener("scroll", this.updateVisibility, false); - // HACK: way to scroll to a newly added card if (dashcard.justAdded) { ReactDOM.findDOMNode(this).scrollIntoView(); @@ -50,21 +47,6 @@ export default class DashCard extends Component { componentWillUnmount() { window.clearInterval(this.visibilityTimer); - window.removeEventListener("scroll", this.updateVisibility, false); - } - - updateVisibility = () => { - const { isFullscreen } = this.props; - const element = ReactDOM.findDOMNode(this); - if (element) { - const rect = element.getBoundingClientRect(); - const isOffscreen = (rect.bottom < 0 || rect.bottom > window.innerHeight || rect.top < 0); - if (isFullscreen && isOffscreen) { - element.style.opacity = 0.05; - } else { - element.style.opacity = 1.0; - } - } } render() { From 9021b863f67a248c37fbcdc6c276f1386a24c9c3 Mon Sep 17 00:00:00 2001 From: Stefano Dissegna Date: Thu, 27 Apr 2017 18:09:31 +0200 Subject: [PATCH 036/202] code cleanup --- project.clj | 4 +- src/metabase/api/card.clj | 11 ++--- src/metabase/api/dataset.clj | 58 +++++++++++++++++-------- src/metabase/api/embed.clj | 7 +-- src/metabase/api/public.clj | 9 ++-- src/metabase/models/query_execution.clj | 6 ++- src/metabase/routes.clj | 12 ++--- 7 files changed, 67 insertions(+), 40 deletions(-) diff --git a/project.clj b/project.clj index 2d64098d31471..9c7463edd0f5e 100644 --- a/project.clj +++ b/project.clj @@ -53,6 +53,7 @@ [com.taoensso/nippy "2.13.0"] ; Fast serialization (i.e., GZIP) library for Clojure [compojure "1.5.2"] ; HTTP Routing library built on Ring [crypto-random "1.2.0"] ; library for generating cryptographically secure random bytes and strings + [dk.ative/docjure "1.11.0"] ; Excel export [environ "1.1.0"] ; easy environment management [hiccup "1.0.5"] ; HTML templating [honeysql "0.8.2"] ; Transform Clojure data structures to SQL @@ -78,8 +79,7 @@ [ring/ring-json "0.4.0"] ; Ring middleware for reading/writing JSON automatically [stencil "0.5.0"] ; Mustache templates for Clojure [toucan "1.0.2" ; Model layer, hydration, and DB utilities - :exclusions [honeysql]] - [dk.ative/docjure "1.11.0"]] ; Excel export + :exclusions [honeysql]]] :repositories [["bintray" "https://dl.bintray.com/crate/crate"]] ; Repo for Crate JDBC driver :plugins [[lein-environ "1.1.0"] ; easy access to environment variables [lein-ring "0.11.0" ; start the HTTP server with 'lein ring server' diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index ca1fe9c404d75..be9c61d0058c6 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -414,17 +414,18 @@ (binding [cache/*ignore-cached-results* ignore_cache] (run-query-for-card card-id, :parameters parameters))) -(api/defendpoint POST "/:card-id/query/:export-format-name" +(api/defendpoint POST "/:card-id/query/:export-format" "Run the query associated with a Card, and return its results as a file in the specified format. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" - [card-id export-format-name parameters] - {parameters (s/maybe su/JSONString)} + [card-id export-format parameters] + {parameters (s/maybe su/JSONString) + export-format dataset-api/export-format-schema} (binding [cache/*ignore-cached-results* true] (dataset-api/as-format - export-format-name + export-format (run-query-for-card card-id :parameters (json/parse-string parameters keyword) :constraints nil - :context :download)))) + :context (dataset-api/export-format-context export-format))))) ;;; ------------------------------------------------------------ Sharing is Caring ------------------------------------------------------------ diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index f6cea226d2a5a..f08e9e51b0094 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -2,6 +2,7 @@ "/api/dataset endpoints." (:require [cheshire.core :as json] [clojure.data.csv :as csv] + [clojure.string :as string] [compojure.core :refer [POST]] [dk.ative.docjure.spreadsheet :as spreadsheet] [metabase @@ -12,7 +13,8 @@ [database :refer [Database]] [query :as query]] [metabase.query-processor.util :as qputil] - [metabase.util.schema :as su])) + [metabase.util.schema :as su] + [schema.core :as s])) (def ^:private ^:const max-results-bare-rows "Maximum number of rows to return specifically on :rows type queries via the API." @@ -45,13 +47,13 @@ (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints default-query-constraints))) 0)}) -(defn ^:private export-to-csv +(defn- export-to-csv [columns rows] (with-out-str ;; turn keywords into strings, otherwise we get colons in our output (csv/write-csv *out* (into [(mapv name columns)] rows)))) -(defn ^:private export-to-xlsx +(defn- export-to-xlsx [columns rows] (let [wb (spreadsheet/create-workbook "Query result" (conj rows (mapv name columns))) ;; note: byte array streams don't need to be closed @@ -59,39 +61,59 @@ (spreadsheet/save-workbook! out wb) (java.io.ByteArrayInputStream. (.toByteArray out)))) -(defn ^:private export-to-json +(defn- export-to-json [columns rows] (for [row rows] (zipmap columns row))) (def ^:private export-formats - {"csv" {:export-fn export-to-csv, :content-type "text/csv", :ext "csv"}, - "xlsx" {:export-fn export-to-xlsx, :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :ext "xlsx"}, - "json" {:export-fn export-to-json, :content-type "applicaton/json", :ext "json"}}) + {"csv" {:export-fn export-to-csv + :content-type "text/csv" + :ext "csv" + :context :csv-download}, + "xlsx" {:export-fn export-to-xlsx + :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + :ext "xlsx" + :context :xlsx-download}, + "json" {:export-fn export-to-json + :content-type "applicaton/json" + :ext "json" + :context :json-download}}) + +(def export-format-schema (apply s/enum (keys export-formats))) + +(defn export-format-context [export-format] + (if-let [export-conf (export-formats export-format)] + (:context export-conf))) (defn as-format "Return a response containing the RESULTS of a query in the specified format." - {:arglists '([export-format-name results])} - [export-format-name {{:keys [columns rows]} :data, :keys [status], :as response}] - (let-404 [export-format (export-formats export-format-name)] + {:arglists '([export-format results])} + [export-format {{:keys [columns rows]} :data, :keys [status], :as response}] + (api/let-404 [export-conf (export-formats export-format)] (if (= status :completed) ;; successful query, send file {:status 200 - :body ((:export-fn export-format) columns rows) - :headers {"Content-Type" (str (:content-type export-format) "; charset=utf-8") - "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) "." (:ext export-format) "\"")}} + :body ((:export-fn export-conf) columns rows) + :headers {"Content-Type" (str (:content-type export-conf) "; charset=utf-8") + "Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) "." (:ext export-conf) "\"")}} ;; failed query, send error message {:status 500 :body (:error response)}))) -(def ^:private export-format-name-regex (re-pattern (str "(" (string/join "|" (keys export-formats)) ")"))) +(def ^:private export-format-regex (re-pattern (str "(" (string/join "|" (keys export-formats)) ")"))) -(defendpoint POST ["/:export-format-name", :export-format-name export-format-name-regex] +(api/defendpoint POST ["/:export-format", :export-format export-format-regex] "Execute a query and download the result data as a file in the specified format." - [export-format-name query] - {query su/JSONString} + [export-format query] + {query su/JSONString + export-format export-format-schema} (let [query (json/parse-string query keyword)] (api/read-check Database (:database query)) - (as-format export-format-name (qp/dataset-query (dissoc query :constraints) {:executed-by api/*current-user-id*, :context :download})))) + (as-format + export-format + (qp/dataset-query + (dissoc query :constraints) + {:executed-by api/*current-user-id*, :context (export-format-context export-format)})))) (api/define-routes) diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index 8fff677cc827f..ac8cf6257c043 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -276,10 +276,11 @@ (run-query-for-unsigned-token (eu/unsign token) query-params)) -(api/defendpoint GET "/card/:token/query/:export-format-name" +(api/defendpoint GET "/card/:token/query/:export-format" "Like `GET /api/embed/card/query`, but returns the results as a file in the specified format." - [token export-format-name & query-params] - (dataset-api/as-format export-format-name (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) + [token export-format & query-params] + {export-format dataset-api/export-format-schema} + (dataset-api/as-format export-format (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil))) ;;; ------------------------------------------------------------ /api/embed/dashboard endpoints ------------------------------------------------------------ diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index e1185a1443950..42412464c4422 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -113,11 +113,12 @@ {parameters (s/maybe su/JSONString)} (run-query-for-card-with-public-uuid uuid parameters)) -(api/defendpoint GET "/card/:uuid/query/:export-format-name" +(api/defendpoint GET "/card/:uuid/query/:export-format" "Fetch a publically-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled." - [uuid export-format-name parameters] - {parameters (s/maybe su/JSONString)} - (dataset-api/as-format export-format-name (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) + [uuid export-format parameters] + {parameters (s/maybe su/JSONString) + export-format dataset-api/export-format-schema} + (dataset-api/as-format export-format (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) ;;; ------------------------------------------------------------ Public Dashboards ------------------------------------------------------------ diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj index 827d522ca1844..851cc8c7f696b 100644 --- a/src/metabase/models/query_execution.clj +++ b/src/metabase/models/query_execution.clj @@ -14,16 +14,18 @@ (def Context "Schema for valid values of QueryExecution `:context`." (s/enum :ad-hoc - :download + :csv-download :dashboard :embedded-dashboard :embedded-question + :json-download :map-tiles :metabot :public-dashboard :public-question :pulse - :question)) + :question + :xlsx-download)) (defn- pre-insert [{context :context, :as query-execution}] (u/prog1 query-execution diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index 8a87602d6cb9a..7021f37add82d 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -34,15 +34,15 @@ (def ^:private embed (partial entrypoint "embed" :embeddable)) (defroutes ^:private public-routes - (GET ["/question/:uuid.:export-format-name" :uuid u/uuid-regex] - [uuid export-format-name] - (resp/redirect (format "/api/public/card/%s/query/%s" uuid export-format-name))) + (GET ["/question/:uuid.:export-format" :uuid u/uuid-regex] + [uuid export-format] + (resp/redirect (format "/api/public/card/%s/query/%s" uuid export-format))) (GET "*" [] public)) (defroutes ^:private embed-routes - (GET "/question/:token.:export-format-name" - [token export-format-name] - (resp/redirect (format "/api/embed/card/%s/query/%s" token export-format-name))) + (GET "/question/:token.:export-format" + [token export-format] + (resp/redirect (format "/api/embed/card/%s/query/%s" token export-format))) (GET "*" [] embed)) ;; Redirect naughty users who try to visit a page other than setup if setup is not yet complete From e05ef2a5d9322e5851114f962b75bf7b9c2ec67a Mon Sep 17 00:00:00 2001 From: Stefano Dissegna Date: Thu, 27 Apr 2017 18:10:08 +0200 Subject: [PATCH 037/202] improved excel export tests by parsing the resulting file back --- test/metabase/api/card_test.clj | 21 ++++++++++--- test/metabase/api/embed_test.clj | 52 ++++++++++++++++++------------- test/metabase/api/public_test.clj | 8 ++++- test/metabase/http_client.clj | 21 +++++++------ 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 91c616058bd0b..5f8a06e78308c 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -1,6 +1,7 @@ (ns metabase.api.card-test "Tests for /api/card endpoints." (:require [cheshire.core :as json] + [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [medley.core :as m] [toucan.db :as db] @@ -399,11 +400,15 @@ ;;; Tests for GET /api/card/:id/xlsx (expect - #(> (.length %1) 0) + [{:col "COUNT(*)"} {:col 75.0}] (do-with-temp-native-card (fn [database-id card] (perms/grant-native-read-permissions! (perms-group/all-users) database-id) - ((user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)))))) + (->> ((user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)) {:request-options {:as :byte-array}}) + (java.io.ByteArrayInputStream.) + (spreadsheet/load-workbook) + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))) ;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json & GET /api/card/:id/query/xlsx **WITH PARAMETERS** @@ -442,10 +447,18 @@ ;; XLSX (expect - #(> (.length %1) 0) + [{:col "COUNT(*)"} {:col 8.0}] (do-with-temp-native-card-with-params (fn [database-id card] - ((user->client :rasta) :post 200 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params))))) + (->> ((user->client :rasta) + :post + 200 + (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params) + {:request-options {:as :byte-array}}) + (java.io.ByteArrayInputStream.) + (spreadsheet/load-workbook) + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))) ;;; +------------------------------------------------------------------------------------------------------------------------+ ;;; | COLLECTIONS | diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 1b8538a9bf062..61e75b872fc86 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -1,6 +1,7 @@ (ns metabase.api.embed-test (:require [buddy.sign.jwt :as jwt] [crypto.random :as crypto-random] + [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [toucan.util.test :as tt] [metabase.http-client :as http] @@ -62,7 +63,12 @@ "" (successful-query-results) "/json" [{:count 100}] "/csv" "count\n100\n" - "/xlsx" #(> (.length %1) 0)))) + "/xlsx" (fn [body] + (->> (java.io.ByteArrayInputStream. body) + (spreadsheet/load-workbook) + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}) + (= [{:col "count"} {:col 100.0}])))))) (defn dissoc-id-and-name {:style/indent 0} [obj] (dissoc obj :id :name)) @@ -137,31 +143,33 @@ "/query" response-format)) -(defmacro ^:private expect-for-response-formats {:style/indent 1} [[response-format-binding] expected actual] +(defmacro ^:private expect-for-response-formats {:style/indent 1} [[response-format-binding request-options-binding] expected actual] `(do - ~@(for [response-format ["" "/json" "/csv" "/xlsx"]] + ~@(for [[response-format request-options] [["" {}] ["/json" {}] ["/csv" {}] ["/xlsx" {:as :byte-array}]]] `(expect - (let [~response-format-binding ~response-format] + (let [~response-format-binding ~response-format + ~request-options-binding {:request-options ~request-options}] ~expected) - (let [~response-format-binding ~response-format] + (let [~response-format-binding ~response-format + ~request-options-binding {:request-options ~request-options}] ~actual))))) ;; it should be possible to run a Card successfully if you jump through the right hoops... -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true}] - (http/client :get 200 (card-query-url card response-format))))) + (http/client :get 200 (card-query-url card response-format) request-options)))) ;; but if the card has an invalid query we should just get a generic "query failed" exception (rather than leaking query info) -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "An error occurred while running the query." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :dataset_query {:database (data/id), :type :native, :native {:query "SELECT * FROM XYZ"}}}] (http/client :get 400 (card-query-url card response-format))))) ;; check that the endpoint doesn't work if embedding isn't enabled -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "Embedding is not enabled." (tu/with-temporary-setting-values [enable-embedding false] (with-new-secret-key @@ -169,14 +177,14 @@ (http/client :get 400 (card-query-url card response-format)))))) ;; check that if embedding *is* enabled globally but not for the Card the request fails -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "Embedding is not enabled for this object." (with-embedding-enabled-and-new-secret-key (with-temp-card [card] (http/client :get 400 (card-query-url card response-format))))) ;; check that if embedding is enabled globally and for the object that requests fail if they are signed with the wrong key -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "Message seems corrupt or manipulated." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true}] @@ -185,21 +193,21 @@ ;;; LOCKED params ;; check that if embedding is enabled globally and for the object requests fail if the token is missing a `:locked` parameter -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "You must specify a value for :abc in the JWT." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] (http/client :get 400 (card-query-url card response-format))))) ;; if `:locked` param is present, request should succeed -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] - (http/client :get 200 (card-query-url card response-format {:params {:abc 100}}))))) + (http/client :get 200 (card-query-url card response-format {:params {:abc 100}}) request-options)))) ;; If `:locked` parameter is present in URL params, request should fail -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "You can only specify a value for :abc in the JWT." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] @@ -208,14 +216,14 @@ ;;; DISABLED params ;; check that if embedding is enabled globally and for the object requests fail if they pass a `:disabled` parameter -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "You're not allowed to specify a value for :abc." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "disabled"}}] (http/client :get 400 (card-query-url card response-format {:params {:abc 100}}))))) ;; If a `:disabled` param is passed in the URL the request should fail -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "You're not allowed to specify a value for :abc." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "disabled"}}] @@ -224,25 +232,25 @@ ;;; ENABLED params ;; If `:enabled` param is present in both JWT and the URL, the request should fail -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] "You can't specify a value for :abc if it's already set in the JWT." (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}] (http/client :get 400 (str (card-query-url card response-format {:params {:abc 100}}) "?abc=200"))))) ;; If an `:enabled` param is present in the JWT, that's ok -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}] - (http/client :get 200 (card-query-url card response-format {:params {:abc "enabled"}}))))) + (http/client :get 200 (card-query-url card response-format {:params {:abc "enabled"}}) request-options)))) ;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok -(expect-for-response-formats [response-format] +(expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}] - (http/client :get 200 (str (card-query-url card response-format) "?abc=200"))))) + (http/client :get 200 (str (card-query-url card response-format) "?abc=200") request-options)))) ;; ------------------------------------------------------------ GET /api/embed/dashboard/:token ------------------------------------------------------------ diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 0f6dc6f813ed7..289d715e059f5 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -1,6 +1,7 @@ (ns metabase.api.public-test "Tests for `api/public/` (public links) endpoints." (:require [cheshire.core :as json] + [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [toucan.db :as db] [toucan.util.test :as tt] @@ -153,9 +154,14 @@ ;; Check that we can exec a PublicCard and get results as XLSX (expect + [{:col "count"} {:col 100.0}] (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-card [{uuid :public_uuid}] - (http/client :get 200 (str "public/card/" uuid "/query/xlsx"))))) + (->> (http/client :get 200 (str "public/card/" uuid "/query/xlsx") {:request-options {:as :byte-array}}) + (java.io.ByteArrayInputStream.) + (spreadsheet/load-workbook) + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))) ;; Check that we can exec a PublicCard with `?parameters` (expect diff --git a/test/metabase/http_client.clj b/test/metabase/http_client.clj index 595e351721f03..4320c9cd84d81 100644 --- a/test/metabase/http_client.clj +++ b/test/metabase/http_client.clj @@ -49,11 +49,13 @@ (defn- parse-response "Deserialize the JSON response or return as-is if that fails." [body] - (try - (auto-deserialize-dates (json/parse-string body keyword)) - (catch Throwable _ - (when-not (s/blank? body) - body)))) + (if (string? body) + (try + (auto-deserialize-dates (json/parse-string body keyword)) + (catch Throwable _ + (when-not (s/blank? body) + body))) + body)) ;;; authentication @@ -104,7 +106,7 @@ :put client/put :delete client/delete)) -(defn- -client [credentials method expected-status url http-body url-param-kwargs] +(defn- -client [credentials method expected-status url http-body url-param-kwargs request-options] ;; Since the params for this function can get a little complicated make sure we validate them {:pre [(or (u/maybe? map? credentials) (string? credentials)) @@ -113,7 +115,7 @@ (string? url) (u/maybe? map? http-body) (u/maybe? map? url-param-kwargs)]} - (let [request-map (build-request-map credentials http-body) + (let [request-map (merge (build-request-map credentials http-body) request-options) request-fn (method->request-fn method) url (build-url url url-param-kwargs) method-name (s/upper-case (name method)) @@ -146,10 +148,11 @@ * URL Base URL of the request, which will be appended to `*url-prefix*`. e.g. `card/1/favorite` * HTTP-BODY-MAP Optional map to send a the JSON-serialized HTTP body of the request * URL-KWARGS key-value pairs that will be encoded and added to the URL as GET params" - {:arglists '([credentials? method expected-status-code? url http-body-map? & url-kwargs])} + {:arglists '([credentials? method expected-status-code? url request-options? http-body-map? & url-kwargs])} [& args] (let [[credentials [method & args]] (u/optional #(or (map? %) (string? %)) args) [expected-status [url & args]] (u/optional integer? args) + [{:keys [request-options]} args] (u/optional #(and (map? %) (:request-options %)) args {:request-options {}}) [body [& {:as url-param-kwargs}]] (u/optional map? args)] - (-client credentials method expected-status url body url-param-kwargs))) + (-client credentials method expected-status url body url-param-kwargs request-options))) From fb5dbb53b90ebec7a88dc4512c0ea8fc392b4f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 10:36:20 -0700 Subject: [PATCH 038/202] Correctly formatted archive page, empty state for filters --- .../components/ArchivedItem.jsx | 0 .../dashboards/components/DashboardList.jsx | 14 ++- .../dashboards/containers/Dashboards.jsx | 5 +- .../containers/DashboardsArchive.jsx | 85 +++++++++---------- .../metabase/questions/containers/Archive.jsx | 2 +- 5 files changed, 49 insertions(+), 57 deletions(-) rename frontend/src/metabase/{questions => }/components/ArchivedItem.jsx (100%) diff --git a/frontend/src/metabase/questions/components/ArchivedItem.jsx b/frontend/src/metabase/components/ArchivedItem.jsx similarity index 100% rename from frontend/src/metabase/questions/components/ArchivedItem.jsx rename to frontend/src/metabase/components/ArchivedItem.jsx diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 34ef97bc91f4a..02296f8024b0c 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -24,11 +24,9 @@ type DashboardListItemType = { } const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover, disableLink}: DashboardListItemType) => { - const WrapperType = disableLink ? 'div' : Link - +const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover}: DashboardListItemType) => { return (
  • - !disableLink && setHover(true)} + onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}> @@ -91,7 +89,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, - +
  • ) }); @@ -101,7 +99,7 @@ export default class DashboardList extends Component { }; render() { - const {dashboards, disableLinks, setFavorited, setArchived} = this.props; + const {dashboards, isArchivePage, setFavorited, setArchived} = this.props; return (
      @@ -109,7 +107,7 @@ export default class DashboardList extends Component { + disableLink={isArchivePage}/> )}
    ); diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 15c822f6d223c..56f308c7c09e7 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -27,7 +27,6 @@ import * as dashboardsActions from "../dashboards"; import {getDashboardListing} from "../selectors"; import {getUser} from "metabase/selectors/user"; - const mapStateToProps = (state, props) => ({ dashboards: getDashboardListing(state), user: getUser(state) @@ -138,7 +137,7 @@ export class Dashboards extends Component { const isLoading = this.props.dashboards === null const noDashboardsCreated = this.props.dashboards && this.props.dashboards.length === 0 const filteredDashboards = isLoading ? [] : this.getFilteredDashboards(); - const noSearchResults = searchText !== "" && filteredDashboards.length === 0; + const noResultsFound = filteredDashboards.length === 0; return ( - { noSearchResults ? + { noResultsFound ?
    ({ - dashboards: getArchivedDashboards(state) + dashboards: getArchivedDashboards(state), + isAdmin: getUserIsAdmin(state, props) }); const mapDispatchToProps = {fetchArchivedDashboards, setArchived}; @@ -31,12 +32,14 @@ export class Dashboards extends Component { props: { dashboards: Dashboard[], fetchArchivedDashboards: () => void, - setArchived: SetArchivedAction + setArchived: SetArchivedAction, + isAdmin: boolean }; state = { searchText: "", } + componentWillMount() { this.props.fetchArchivedDashboards(); } @@ -70,52 +73,44 @@ export class Dashboards extends Component { return ( - { noDashboardsArchived ? -
    - You haven't archived any dashboards yet.} - image="/app/img/dashboard_illustration" - action="Create a dashboard" - onActionClick={this.showCreateDashboard} - className="mt2" - imageClassName="mln2" + +
    +
    + +
    +
    + this.setState({searchText: text})} />
    - :
    -
    - -
    -
    - this.setState({searchText: text})} + { noSearchResults ? +
    + +

    No results found

    +

    Try adjusting your filter to find what you’re + looking for.

    +
    + } + image="/app/img/empty_dashboard" + imageClassName="mln2" + smallDescription />
    - { noSearchResults ? -
    - -

    No results found

    -

    Try adjusting your filter to find what you’re - looking for.

    -
    - } - image="/app/img/empty_dashboard" - action="Create a dashboard" - imageClassName="mln2" - smallDescription - /> -
    - : - } -
    - - } + :
    + { filteredDashboards.map((dashboard) => + { + await this.props.setArchived(dashboard.id, false); + }}/> + )} +
    + } +
    ); } diff --git a/frontend/src/metabase/questions/containers/Archive.jsx b/frontend/src/metabase/questions/containers/Archive.jsx index 2d88bd8a8fbe6..363fc3e0b48e4 100644 --- a/frontend/src/metabase/questions/containers/Archive.jsx +++ b/frontend/src/metabase/questions/containers/Archive.jsx @@ -3,7 +3,7 @@ import { connect } from "react-redux"; import HeaderWithBack from "metabase/components/HeaderWithBack"; import SearchHeader from "metabase/components/SearchHeader"; -import ArchivedItem from "../components/ArchivedItem"; +import ArchivedItem from "../../components/ArchivedItem"; import { loadEntities, setArchived, setSearchText } from "../questions"; import { setCollectionArchived } from "../collections"; From a62ab78a549baff77945c63eda497f58e795744c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 11:06:31 -0700 Subject: [PATCH 039/202] Add empty state ui to dashboards archive --- .../containers/DashboardsArchive.jsx | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx index df807bd738b9a..e1e085cfee076 100644 --- a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx +++ b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx @@ -58,10 +58,6 @@ export class Dashboards extends Component { .value() } - updateSection = (section: ListFilterWidgetItem) => { - this.setState({section}); - } - render() { let {searchText} = this.state; @@ -70,47 +66,61 @@ export class Dashboards extends Component { const filteredDashboards = isLoading ? [] : this.getFilteredDashboards(); const noSearchResults = searchText !== "" && filteredDashboards.length === 0; + const headerWithBackContainer = +
    + +
    + return ( - -
    -
    - -
    -
    - this.setState({searchText: text})} - /> -
    - { noSearchResults ? -
    + { noDashboardsArchived ? +
    + {headerWithBackContainer} +
    -

    No results found

    -

    Try adjusting your filter to find what you’re - looking for.

    -
    - } - image="/app/img/empty_dashboard" - imageClassName="mln2" - smallDescription + message={"No dashboards have been archived yet"} + icon="viewArchive" />
    - :
    - { filteredDashboards.map((dashboard) => - { - await this.props.setArchived(dashboard.id, false); - }}/> - )} +
    + :
    + {headerWithBackContainer} +
    + this.setState({searchText: text})} + />
    - } -
    + { noSearchResults ? +
    + +

    No results found

    +

    Try adjusting your filter to find what you’re + looking for.

    +
    + } + image="/app/img/empty_dashboard" + imageClassName="mln2" + smallDescription + /> +
    + :
    + { filteredDashboards.map((dashboard) => + { + await this.props.setArchived(dashboard.id, false); + }}/> + )} +
    + } +
    + }
    ); } From a28da95978113f691acbdbb0bc84846fc85f2d05 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 11:13:19 -0700 Subject: [PATCH 040/202] Dashboard drill through first pass --- .../dashboard/components/DashCard.jsx | 24 +++++++++++++------ .../dashboard/components/DashboardGrid.jsx | 2 ++ .../dashboard/containers/DashboardApp.jsx | 6 +++-- frontend/src/metabase/dashboard/dashboard.js | 9 +++++++ frontend/src/metabase/qb/lib/modes.js | 5 ++++ .../components/Visualization.jsx | 11 ++++++--- 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index b632dc96da266..fbd1d58f29a93 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -24,12 +24,6 @@ const HEADER_ACTION_STYLE = { padding: 4 }; -// const mapStateToProps = (state, props) => ({ -// }) -// const mapDispatchToProps = { -// } -// -// @connect(mapStateToProps, mapDispatchToProps) export default class DashCard extends Component { static propTypes = { dashcard: PropTypes.object.isRequired, @@ -74,7 +68,18 @@ export default class DashCard extends Component { } render() { - const { dashcard, dashcardData, cardDurations, parameterValues, isEditing, isEditingParameter, onAddSeries, onRemove, linkToCard } = this.props; + const { + dashcard, + dashcardData, + cardDurations, + parameterValues, + isEditing, + isEditingParameter, + onAddSeries, + onRemove, + linkToCard, + metadata + } = this.props; const mainCard = { ...dashcard.card, @@ -115,6 +120,9 @@ export default class DashCard extends Component { errorIcon = "warning"; } + const sourceTable = getIn(dashcard, ["card", "dataset_query", "query", "source_table"]); + const tableMetadata = sourceTable == null ? null : getIn(metadata, ["tables", sourceTable]); + return (
    } linkToCard={linkToCard} + tableMetadata={tableMetadata} + onChangeCardAndRun={this.props.onChangeCardAndRun} />
    ); diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index 4441f0249d1d3..3c01735ae72b7 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -197,7 +197,9 @@ export default class DashboardGrid extends Component { onAddSeries={this.onDashCardAddSeries.bind(this, dc)} onUpdateVisualizationSettings={this.props.onUpdateDashCardVisualizationSettings.bind(this, dc.id)} onReplaceAllVisualizationSettings={this.props.onReplaceAllDashCardVisualizationSettings.bind(this, dc.id)} + onChangeCardAndRun={this.props.onChangeCardAndRun} linkToCard={this.props.linkToCard} + metadata={this.props.metadata} /> ) } diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index fa643613ccb74..c095e7c47a5a0 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -11,7 +11,7 @@ import { fetchDatabaseMetadata } from "metabase/redux/metadata"; import { setErrorPage } from "metabase/redux/app"; import { getIsEditing, getIsEditingParameter, getIsDirty, getDashboardComplete, getCardList, getRevisions, getCardData, getCardDurations, getEditingParameter, getParameterValues } from "../selectors"; -import { getDatabases } from "metabase/selectors/metadata"; +import { getDatabases, getMetadata } from "metabase/selectors/metadata"; import { getUserIsAdmin } from "metabase/selectors/user"; import * as dashboardActions from "../dashboard"; @@ -31,7 +31,9 @@ const mapStateToProps = (state, props) => { databases: getDatabases(state, props), editingParameter: getEditingParameter(state, props), parameterValues: getParameterValues(state, props), - addCardOnLoad: props.location.query.add ? parseInt(props.location.query.add) : null + addCardOnLoad: props.location.query.add ? parseInt(props.location.query.add) : null, + + metadata: getMetadata(state) } } diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 9c8d650f51f5f..3330682eb6c14 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -498,6 +498,15 @@ export const deletePublicLink = createAction(DELETE_PUBLIC_LINK, async ({ id }) return { id }; }); +import * as Urls from "metabase/lib/urls"; +import { push } from "react-router-redux"; + +const CHANGE_CARD_AND_RUN = "metabase/database/CHANGE_CARD_AND_RUN"; +export const onChangeCardAndRun = createThunkAction(CHANGE_CARD_AND_RUN, card => + (dispatch, getState) => { + dispatch(push(Urls.question(null, card))); + }); + // reducers const dashboardId = handleActions({ diff --git a/frontend/src/metabase/qb/lib/modes.js b/frontend/src/metabase/qb/lib/modes.js index b988062e09a47..197100e59e6d6 100644 --- a/frontend/src/metabase/qb/lib/modes.js +++ b/frontend/src/metabase/qb/lib/modes.js @@ -4,6 +4,7 @@ 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 Utils from "metabase/lib/utils"; import SegmentMode from "../components/modes/SegmentMode"; import MetricMode from "../components/modes/MetricMode"; @@ -86,6 +87,8 @@ export const getModeActions = ( tableMetadata: ?TableMetadata ): ClickAction[] => { if (mode && card && tableMetadata) { + // FIXME: copy card because it may be frozen and action may mutate it :-/ + card = Utils.copy(card); const props: ClickActionProps = { card, tableMetadata }; // flatten array of arrays return [].concat( @@ -102,6 +105,8 @@ export const getModeDrills = ( clicked: ?ClickObject ): ClickAction[] => { if (mode && card && tableMetadata && clicked) { + // FIXME: copy card because it may be frozen and action may mutate it :-/ + card = Utils.copy(card); const props: ClickActionProps = { card, tableMetadata, clicked }; // flatten array of arrays return [].concat( diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index bfc935a93c8a4..b772404286535 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -18,7 +18,7 @@ import { isSameSeries } from "metabase/visualizations/lib/utils"; import Utils from "metabase/lib/utils"; import { datasetContainsNoResults } from "metabase/lib/dataset"; -import { getModeDrills } from "metabase/qb/lib/modes" +import { getMode, getModeDrills } from "metabase/qb/lib/modes" import { MinRowsError, ChartSettingsError } from "metabase/visualizations/lib/errors"; @@ -186,9 +186,14 @@ export default class Visualization extends Component<*, Props, State> { this.setState({ hovered }); } + getMode() { + const { series: [{ card }], tableMetadata } = this.props; + return this.props.mode || getMode(card, tableMetadata); + } + getClickActions(clicked: ?ClickObject) { - const { mode, series: [{ card }], tableMetadata } = this.props; - return getModeDrills(mode, card, tableMetadata, clicked); + const { series: [{ card }], tableMetadata } = this.props; + return getModeDrills(this.getMode(), card, tableMetadata, clicked); } visualizationIsClickable = (clicked: ClickObject) => { From e03e778d76d472b4721a332fd3faf02c214ba60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 11:13:50 -0700 Subject: [PATCH 041/202] Ensure that dashboards are always listed in alphabetic order --- frontend/src/metabase/dashboards/containers/Dashboards.jsx | 1 + .../src/metabase/dashboards/containers/DashboardsArchive.jsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 56f308c7c09e7..a1015455435d9 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -125,6 +125,7 @@ export class Dashboards extends Component { .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) .filter(this.sectionFilter(section)) .value() + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) } updateSection = (section: ListFilterWidgetItem) => { diff --git a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx index e1e085cfee076..ddd3ab7910985 100644 --- a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx +++ b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx @@ -55,7 +55,9 @@ export class Dashboards extends Component { return _.chain(dashboards) .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) + .sortBy((dash) => dash.name.toLowerCase()) .value() + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) } render() { @@ -81,7 +83,7 @@ export class Dashboards extends Component { {headerWithBackContainer}
    No dashboards have been
    archived yet} icon="viewArchive" />
    From 0f03557df21b10f7b1890021c726f5c2e88576cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 11:58:59 -0700 Subject: [PATCH 042/202] Replace dashboard remove button with archive button --- ...ardModal.jsx => ArchiveDashboardModal.jsx} | 12 ++--- .../dashboard/components/Dashboard.jsx | 4 +- .../dashboard/components/DashboardHeader.jsx | 22 +++++----- .../dashboard/containers/DashboardApp.jsx | 4 +- .../dashboards/components/DashboardList.jsx | 2 +- .../dashboards/containers/Dashboards.jsx | 44 ++++++++++--------- .../src/metabase/dashboards/dashboards.js | 8 +--- 7 files changed, 47 insertions(+), 49 deletions(-) rename frontend/src/metabase/dashboard/components/{DeleteDashboardModal.jsx => ArchiveDashboardModal.jsx} (85%) diff --git a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx similarity index 85% rename from frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx rename to frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx index f75223b38abe7..78922e6ebbd97 100644 --- a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx +++ b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import ModalContent from "metabase/components/ModalContent.jsx"; -export default class DeleteDashboardModal extends Component { +export default class ArchiveDashboardModal extends Component { constructor(props, context) { super(props, context); @@ -16,12 +16,12 @@ export default class DeleteDashboardModal extends Component { dashboard: PropTypes.object.isRequired, onClose: PropTypes.func, - onDelete: PropTypes.func + onArchive: PropTypes.func }; - async deleteDashboard() { + async archiveDashboard() { try { - this.props.onDelete(this.props.dashboard); + this.props.onArchive(this.props.dashboard); } catch (error) { this.setState({ error }); } @@ -46,7 +46,7 @@ export default class DeleteDashboardModal extends Component { return (
    @@ -54,7 +54,7 @@ export default class DeleteDashboardModal extends Component {
    - + {formError}
    diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index 578c71a8f6d97..a73c9ffbfe162 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -39,7 +39,7 @@ type Props = { initialize: () => Promise, addCardToDashboard: ({ dashId: DashCardId, cardId: CardId }) => void, - deleteDashboard: (dashboardId: DashboardId) => void, + archiveDashboard: (dashboardId: DashboardId) => void, fetchCards: (filterMode?: string) => void, fetchDashboard: (dashboardId: DashboardId, queryParams: ?QueryParams) => void, fetchRevisions: ({ entity: string, id: number }) => void, @@ -91,7 +91,7 @@ export default class Dashboard extends Component<*, Props, State> { parameters: PropTypes.array, addCardToDashboard: PropTypes.func.isRequired, - deleteDashboard: PropTypes.func.isRequired, + archiveDashboard: PropTypes.func.isRequired, fetchCards: PropTypes.func.isRequired, fetchDashboard: PropTypes.func.isRequired, fetchRevisions: PropTypes.func.isRequired, diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index ebeb8212f9bd1..c73a2fc8e9d3f 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import ActionButton from "metabase/components/ActionButton.jsx"; import AddToDashSelectQuestionModal from "./AddToDashSelectQuestionModal.jsx"; -import DeleteDashboardModal from "./DeleteDashboardModal.jsx"; +import ArchiveDashboardModal from "./ArchiveDashboardModal.jsx"; import Header from "metabase/components/Header.jsx"; import HistoryModal from "metabase/components/HistoryModal.jsx"; import Icon from "metabase/components/Icon.jsx"; @@ -47,7 +47,7 @@ type Props = { parameters: React$Element<*>[], addCardToDashboard: ({ dashId: DashCardId, cardId: CardId }) => void, - deleteDashboard: (dashboardId: DashboardId) => void, + archiveDashboard: (dashboardId: DashboardId) => void, fetchCards: (filterMode?: string) => void, fetchDashboard: (dashboardId: DashboardId, queryParams: ?QueryParams) => void, fetchRevisions: ({ entity: string, id: number }) => void, @@ -87,7 +87,7 @@ export default class DashboardHeader extends Component<*, Props, State> { refreshElapsed: PropTypes.number, addCardToDashboard: PropTypes.func.isRequired, - deleteDashboard: PropTypes.func.isRequired, + archiveDashboard: PropTypes.func.isRequired, fetchCards: PropTypes.func.isRequired, fetchDashboard: PropTypes.func.isRequired, fetchRevisions: PropTypes.func.isRequired, @@ -123,8 +123,8 @@ export default class DashboardHeader extends Component<*, Props, State> { this.onDoneEditing(); } - async onDelete() { - await this.props.deleteDashboard(this.props.dashboard.id); + async onArchive() { + await this.props.archiveDashboard(this.props.dashboard.id); this.props.onChangeLocation("/dashboards"); } @@ -150,15 +150,15 @@ export default class DashboardHeader extends Component<*, Props, State> { Cancel , - this.refs.deleteDashboardModal.toggle()} - onDelete={() => this.onDelete()} + onClose={() => this.refs.ArchiveDashboardModal.toggle()} + onArchive={() => this.onArchive()} /> , { return { @@ -40,7 +40,7 @@ const mapStateToProps = (state, props) => { const mapDispatchToProps = { ...dashboardActions, - deleteDashboard, + archiveDashboard, fetchDatabaseMetadata, setErrorPage, onChangeLocation: push diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 02296f8024b0c..ec878981ef4fe 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -38,7 +38,7 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, boxShadow: "0 1px 3px 0 rgba(220,220,220,0.50)", height: "80px" }} - onMouseEnter={() => setHover(true)} + onMouseOver={() => setHover(true)} onMouseLeave={() => setHover(false)}> diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index a1015455435d9..4a772105d4511 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -143,11 +143,31 @@ export class Dashboards extends Component { return ( { modalOpen ? this.renderCreateDashboardModal() : null } +
    + + +
    + + + + + {!noDashboardsCreated && + + } +
    +
    { noDashboardsCreated ? -
    +
    Put the charts and graphs you look at
    frequently in a single, handy place.} image="/app/img/dashboard_illustration" @@ -158,25 +178,7 @@ export class Dashboards extends Component { />
    :
    -
    - - -
    - - - - - -
    -
    -
    +
    this.setState({searchText: text})} diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index 9323bbf2f0fab..3dee64db5713f 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -98,12 +98,6 @@ export const updateDashboard = createThunkAction(UPDATE_DASHBOARD, (dashboard: D } ); -export const deleteDashboard = createAction(DELETE_DASHBOARD, async (dashId) => { - MetabaseAnalytics.trackEvent("Dashboard", "Delete"); - await DashboardApi.delete({ dashId }); - return dashId; -}); - export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashboard: Dashboard) { return async function(dispatch, getState): Promise { let { id, name, description, parameters } = dashboard @@ -155,6 +149,8 @@ export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, un return response; } }); +// Convenience shorthand +export const archiveDashboard = async (dashId) => await setArchived(dashId, true); const archive = handleActions({ [FETCH_ARCHIVE]: (state, { payload }) => payload, From f2e5f98b664d392939e503c50e8bd5f4edadc7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 12:40:42 -0700 Subject: [PATCH 043/202] Fix flickering by giving to the empty search result image an explicit height --- frontend/src/metabase/components/EmptyState.jsx | 5 +++-- frontend/src/metabase/dashboards/containers/Dashboards.jsx | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx index 2aed7096b7a40..2facce7fa534a 100644 --- a/frontend/src/metabase/components/EmptyState.jsx +++ b/frontend/src/metabase/components/EmptyState.jsx @@ -15,6 +15,7 @@ type EmptyStateProps = { title?: string, icon?: string, image?: string, + imageHeight?: number, // for reducing ui flickering when the image is loading imageClassName?: string, action?: string, link?: string, @@ -22,7 +23,7 @@ type EmptyStateProps = { smallDescription?: boolean } -const EmptyState = ({title, message, icon, image, imageClassName, action, link, onActionClick, smallDescription = false}: EmptyStateProps) => +const EmptyState = ({title, message, icon, image, imageHeight, imageClassName, action, link, onActionClick, smallDescription = false}: EmptyStateProps) =>
    { title &&

    {title}

    @@ -31,7 +32,7 @@ const EmptyState = ({title, message, icon, image, imageClassName, action, link, } { image && - {message} }
    diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 4a772105d4511..f1768752502f3 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -202,6 +202,7 @@ export class Dashboards extends Component {
    } image="/app/img/empty_dashboard" + imageHeight="210px" action="Create a dashboard" imageClassName="mln2" smallDescription From c21c635276affade505c4391d504a9b4caaf9b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 13:24:52 -0700 Subject: [PATCH 044/202] Dashboard item UI changes --- .../dashboards/components/DashboardList.jsx | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index ec878981ef4fe..c5d813c9915c2 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -29,28 +29,30 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover(true)} onMouseLeave={() => setHover(false)}> -
    -

    +

    {dashboard.name} -

    +
    + className={cx("text-smaller text-uppercase text-bold text-grey-3")}> {/* NOTE: Could these time formats be centrally stored somewhere? */} {moment(dashboard.created_at).format('MMM D, YYYY')}
    @@ -59,9 +61,9 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, { (dashboard.archived || hover) && { e.preventDefault(); setArchived(dashboard.id, !dashboard.archived, true) @@ -74,11 +76,11 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, { e.preventDefault(); setFavorited(dashboard.id, !dashboard.favorite) @@ -89,6 +91,11 @@ const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover,
    + + { !hover && !dashboard.favorite && + + } ) }); From f5929aa6f3685557781015a50e64c428b6a32ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 14:25:17 -0700 Subject: [PATCH 045/202] Cool archival animation --- .../dashboards/components/DashboardList.jsx | 175 ++++++++++-------- 1 file changed, 97 insertions(+), 78 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index c5d813c9915c2..211ed2a7edf8a 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -3,7 +3,6 @@ import React, {Component} from "react"; import PropTypes from "prop-types"; import {Link} from "react-router"; -import {withState} from "recompose"; import cx from "classnames"; import moment from "moment"; @@ -14,91 +13,111 @@ import Icon from "metabase/components/Icon"; import Ellipsified from "metabase/components/Ellipsified.jsx"; import Tooltip from "metabase/components/Tooltip"; -type DashboardListItemType = { +type DashboardListItemProps = { dashboard: Dashboard, - hover: boolean, - setHover: (boolean) => void, setFavorited: (dashId: number, favorited: boolean) => void, - setArchived: (dashId: number, archived: boolean) => void, - disableLink: boolean + setArchived: (dashId: number, archived: boolean) => void } -const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, setFavorited, setArchived, hover, setHover}: DashboardListItemType) => { - return (
  • - setHover(true)} - onMouseLeave={() => setHover(false)}> -
    -
    +class DashboardListItem extends Component { + props: DashboardListItemProps + + state = { + hover: false, + fadingOut: false + } + + render() { + const {dashboard, setFavorited, setArchived} = this.props + const {hover, fadingOut} = this.state + + const {id, name, created_at, archived, favorite} = dashboard + + const archivalButton = + + { + e.preventDefault(); + this.setState({fadingOut: true}) + setTimeout(() => setArchived(id, !archived, true), 300); + } } + /> + + + const favoritingButton = + + { + e.preventDefault(); + setFavorited(id, !favorite) + } } + /> + + + const dashboardIcon = + + + return ( +
  • + this.setState({hover: true})} + onMouseLeave={() => this.setState({hover: false})}>
    -

    - {dashboard.name} -

    -
    - {/* NOTE: Could these time formats be centrally stored somewhere? */} - {moment(dashboard.created_at).format('MMM D, YYYY')} +
    +
    +

    + {name} +

    +
    + {/* NOTE: Could these time formats be centrally stored somewhere? */} + {moment(created_at).format('MMM D, YYYY')} +
    +
    +
    + { (archived || hover) && archivalButton } + { setFavorited && (favorite || hover) && favoritingButton } +
    -
    - { (dashboard.archived || hover) && - - { - e.preventDefault(); - setArchived(dashboard.id, !dashboard.archived, true) - } } - /> - - } - { setFavorited && (dashboard.favorite || hover) && - - { - e.preventDefault(); - setFavorited(dashboard.id, !dashboard.favorite) - } } - /> - - } -
    -
    -
  • - { !hover && !dashboard.favorite && - - } - - ) -}); + { !hover && !favorite && dashboardIcon } + + + ) + } + +} export default class DashboardList extends Component { static propTypes = { From 4e934dbe88211d3b1f41a24dc676fe38fed30014 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 15:44:58 -0700 Subject: [PATCH 046/202] Handle multiseries --- .../dashboard/components/DashCard.jsx | 5 +--- frontend/src/metabase/meta/Card.js | 8 +++++ .../components/Visualization.jsx | 30 +++++++++---------- .../visualizations/lib/LineAreaBarRenderer.js | 15 ++++++---- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index a7f4dd7986a9d..621e745929383 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -102,9 +102,6 @@ export default class DashCard extends Component { errorIcon = "warning"; } - const sourceTable = getIn(dashcard, ["card", "dataset_query", "query", "source_table"]); - const tableMetadata = sourceTable == null ? null : getIn(metadata, ["tables", sourceTable]); - return (
    } linkToCard={linkToCard} - tableMetadata={tableMetadata} + metadata={metadata} onChangeCardAndRun={this.props.onChangeCardAndRun} />
    diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index 9032a759139b6..97361584f1743 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -64,6 +64,14 @@ export function getQuery(card: Card): ?StructuredQuery { } } +export function getTableMetadata(card: Card, metadata): ?TableMetadata { + const query = getQuery(card); + if (query) { + return metadata.tables[query.source_table] || null; + } + return null; +} + export function getTemplateTags(card: ?Card): Array { return card && card.dataset_query.type === "native" && card.dataset_query.native.template_tags ? Object.values(card.dataset_query.native.template_tags) : diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 981bc5acbf81f..eee1c94a4f13b 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -19,6 +19,7 @@ import { isSameSeries } from "metabase/visualizations/lib/utils"; import Utils from "metabase/lib/utils"; import { datasetContainsNoResults } from "metabase/lib/dataset"; import { getMode, getModeDrills } from "metabase/qb/lib/modes" +import * as Card from "metabase/meta/Card"; import { MinRowsError, ChartSettingsError } from "metabase/visualizations/lib/errors"; @@ -29,9 +30,8 @@ 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 { 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"; +import type { Card as CardObject, VisualizationSettings } from "metabase/meta/types/Card"; +import type { HoverObject, ClickObject, Series } from "metabase/meta/types/Visualization"; type Props = { series: Series, @@ -60,9 +60,8 @@ type Props = { settings: VisualizationSettings, // for click actions - mode?: QueryMode, - tableMetadata: TableMetadata, - onChangeCardAndRun: (card: Card) => void, + metadata: null, // FIXME + onChangeCardAndRun: (card: CardObject) => void, // used for showing content in place of visualization, e.x. dashcard filter mapping replacementContent: Element, @@ -186,14 +185,16 @@ export default class Visualization extends Component<*, Props, State> { this.setState({ hovered }); } - getMode() { - const { series: [{ card }], tableMetadata } = this.props; - return this.props.mode || getMode(card, tableMetadata); - } - getClickActions(clicked: ?ClickObject) { - const { series: [{ card }], tableMetadata } = this.props; - return getModeDrills(this.getMode(), card, tableMetadata, clicked); + if (!clicked) { + return []; + } + const { series, metadata } = this.props; + const seriesIndex = clicked.seriesIndex || 0; + const card = series[seriesIndex].card; + const tableMetadata = Card.getTableMetadata(card, metadata); + const mode = getMode(card, tableMetadata); + return getModeDrills(mode, card, tableMetadata, clicked); } visualizationIsClickable = (clicked: ClickObject) => { @@ -209,7 +210,6 @@ export default class Visualization extends Component<*, Props, State> { } handleVisualizationClick = (clicked: ClickObject) => { - console.log("clicked", clicked) // needs to be delayed so we don't clear it when switching from one drill through to another setTimeout(() => { // const { onChangeCardAndRun } = this.props; @@ -301,8 +301,6 @@ export default class Visualization extends Component<*, Props, State> { }; } - console.log("clicked", clicked); - return (
    { showTitle && (settings["card.title"] || extra) && (loading || error || noResults || !(CardVisualization && CardVisualization.noHeader)) || replacementContent ? diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index b5dc4bd92fb34..022e1b55f8511 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -404,11 +404,16 @@ function applyChartTooltips(chart, series, isStacked, onHoverChange, onVisualiza } } - if (clicked && series.length > 1 && card._breakoutColumn) { - clicked.dimensions.push({ - value: card._breakoutValue, - column: card._breakoutColumn - }); + // handle multiseries + if (clicked && series.length > 1) { + if (card._breakoutColumn) { + clicked.dimensions.push({ + value: card._breakoutValue, + column: card._breakoutColumn + }); + } else { + clicked.seriesIndex = seriesIndex; + } } if (clicked) { From 99f14c51dcdeaafd54ee9aef10e71265f4fcf0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 15:49:36 -0700 Subject: [PATCH 047/202] Experimental list item crossfade --- .../dashboards/components/DashboardList.jsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 211ed2a7edf8a..b2d263dc78029 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -66,7 +66,7 @@ class DashboardListItem extends Component { const dashboardIcon = return ( @@ -104,14 +104,29 @@ class DashboardListItem extends Component { {moment(created_at).format('MMM D, YYYY')}
    -
    - { (archived || hover) && archivalButton } - { setFavorited && (favorite || hover) && favoritingButton } + +
    + { hover && archivalButton } + { (favorite || hover) && favoritingButton } + { !hover && !favorite && dashboardIcon } +
    + +
    + { favorite ? favoritingButton : dashboardIcon} +
    + +
    + { favoritingButton } +
    + +
    + { archivalButton } + { favoritingButton }
    +
    - { !hover && !favorite && dashboardIcon } ) From 687e62e0fd4fcd06095c5659f6bd9d97f4ca8261 Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 16:25:59 -0700 Subject: [PATCH 048/202] Add 'All Time' operator for timeseries time widget --- .../qb/components/TimeseriesFilterWidget.jsx | 2 +- .../components/filters/pickers/DatePicker.jsx | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx index 4be6045046318..23a1601f49adf 100644 --- a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx +++ b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx @@ -121,12 +121,12 @@ export default class TimeseriesFilterWidget extends Component<*, Props, State> { sizeToFit > { this.setState({ filter: newFilter }); }} tableMetadata={tableMetadata} + includeAllTime />
    + {/* Hidden flexbox item which makes sure that long titles are ellipsified correctly */}
    { hover && archivalButton } { (favorite || hover) && favoritingButton } { !hover && !favorite && dashboardIcon }
    -
    - { favorite ? favoritingButton : dashboardIcon} + {/* Non-hover dashboard icon, only rendered if the dashboard isn't favorited */} + {!favorite && +
    + { dashboardIcon }
    + } -
    + {/* Favorite icon, only rendered if the dashboard is favorited */} + {/* Visible also in the hover state (under other button) because hiding leads to an ugly animation */} + {favorite && +
    { favoritingButton }
    + } -
    + {/* Hover state buttons, both archival and favoriting */} +
    { archivalButton } { favoritingButton }
    diff --git a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx index ddd3ab7910985..3414b1f097206 100644 --- a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx +++ b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx @@ -12,7 +12,6 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import SearchHeader from "metabase/components/SearchHeader"; import EmptyState from "metabase/components/EmptyState"; import ArchivedItem from "metabase/components/ArchivedItem"; -import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; import {caseInsensitiveSearch} from "metabase/lib/string" diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index 3dee64db5713f..62c86becbbbb8 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -1,14 +1,12 @@ /* @flow weak */ -import { handleActions, createAction, combineReducers, createThunkAction } from "metabase/lib/redux"; +import { handleActions, combineReducers, createThunkAction } from "metabase/lib/redux"; import MetabaseAnalytics from "metabase/lib/analytics"; -import { inflect } from "metabase/lib/formatting"; import * as Urls from "metabase/lib/urls"; import { DashboardApi } from "metabase/services"; import { addUndo } from "metabase/redux/undo"; import React from "react"; -import {Link} from "react-router"; import { push } from "react-router-redux"; import moment from 'moment'; From 280339f1be07e539aaae8869dd3063b954e21073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atte=20Kein=C3=A4nen?= Date: Thu, 27 Apr 2017 17:05:23 -0700 Subject: [PATCH 051/202] Flow types --- frontend/interfaces/underscore.js | 2 ++ frontend/src/metabase/components/EmptyState.jsx | 2 +- .../metabase/dashboards/containers/Dashboards.jsx | 4 +++- frontend/src/metabase/dashboards/dashboards.js | 2 +- frontend/src/metabase/meta/types/User.js | 13 +++++++++++++ 5 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 frontend/src/metabase/meta/types/User.js diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js index 00fc5ce0ede50..b0bab2bfe69ec 100644 --- a/frontend/interfaces/underscore.js +++ b/frontend/interfaces/underscore.js @@ -60,4 +60,6 @@ declare module "underscore" { // TODO: improve this declare function chain(obj: S): any; + + declare function constant(obj: S): () => S; } diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx index 2facce7fa534a..c55d38c4ea108 100644 --- a/frontend/src/metabase/components/EmptyState.jsx +++ b/frontend/src/metabase/components/EmptyState.jsx @@ -15,7 +15,7 @@ type EmptyStateProps = { title?: string, icon?: string, image?: string, - imageHeight?: number, // for reducing ui flickering when the image is loading + imageHeight?: string, // for reducing ui flickering when the image is loading imageClassName?: string, action?: string, link?: string, diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index f1768752502f3..29fc12be000eb 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -23,6 +23,7 @@ import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; import {caseInsensitiveSearch} from "metabase/lib/string" import type {SetFavoritedAction, SetArchivedAction} from "../dashboards"; +import type {User} from "metabase/meta/types/User" import * as dashboardsActions from "../dashboards"; import {getDashboardListing} from "../selectors"; import {getUser} from "metabase/selectors/user"; @@ -65,7 +66,8 @@ export class Dashboards extends Component { createDashboard: (Dashboard) => any, fetchDashboards: () => void, setFavorited: SetFavoritedAction, - setArchived: SetArchivedAction + setArchived: SetArchivedAction, + user: User }; state = { diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index 62c86becbbbb8..a37b1b0f1cbda 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -128,7 +128,7 @@ function createUndo(type, action) { }; } -export type SetArchivedAction = (dashId: number, archived: boolean, undoable: boolean) => void; +export type SetArchivedAction = (dashId: number, archived: boolean, undoable?: boolean) => void; export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, undoable = false) => { return async (dispatch, getState) => { const response = await DashboardApi.update({ diff --git a/frontend/src/metabase/meta/types/User.js b/frontend/src/metabase/meta/types/User.js new file mode 100644 index 0000000000000..2b1d4440ecfc5 --- /dev/null +++ b/frontend/src/metabase/meta/types/User.js @@ -0,0 +1,13 @@ +export type User = { + common_name: string, + date_joined: string, + email: string, + first_name: string, + google_auth: boolean, + id: number, + is_active: boolean, + is_qbnewb: false, + is_superuser: true, + last_login: string, + last_name: string +} \ No newline at end of file From 30ef8d07ef7298c6a9eb31f1b792035dfbb944ce Mon Sep 17 00:00:00 2001 From: Tom Robinson Date: Thu, 27 Apr 2017 17:05:56 -0700 Subject: [PATCH 052/202] Fix lint and flow errors --- .../public/components/MetabaseEmbed.jsx | 27 ------------ .../qb/components/drill/ObjectDetailDrill.jsx | 5 --- .../qb/components/drill/SortAction.jsx | 41 +++++++++++-------- .../components/drill/SummarizeColumnDrill.js | 3 +- .../components/drill/TimeseriesPivotDrill.jsx | 2 - .../drill/UnderlyingRecordsDrill.jsx | 2 - .../components/filters/pickers/DatePicker.jsx | 6 +-- .../components/ChartClickActions.jsx | 3 +- 8 files changed, 31 insertions(+), 58 deletions(-) delete mode 100644 frontend/src/metabase/public/components/MetabaseEmbed.jsx diff --git a/frontend/src/metabase/public/components/MetabaseEmbed.jsx b/frontend/src/metabase/public/components/MetabaseEmbed.jsx deleted file mode 100644 index 80635a96ca845..0000000000000 --- a/frontend/src/metabase/public/components/MetabaseEmbed.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { Component } from "react"; - -import querystring from "querystring"; -import _ from "underscore"; - -const OPTION_NAMES = ["bordered"]; - -export default class MetabaseEmbed extends Component { - render() { - let { className, style, url } = this.props; - - let options = querystring.stringify(_.pick(this.props, ...OPTION_NAMES)); - if (options) { - url += "#" + options; - } - - return ( -