From db08877b361ab532323933022120f85e418c8f40 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 21 Sep 2017 22:20:43 -0400 Subject: [PATCH 1/8] Adding full Annotation Framework --- superset/assets/backendSync.json | 1135 +++++++++++++---- superset/assets/javascripts/chart/Chart.jsx | 5 +- .../javascripts/chart/ChartContainer.jsx | 1 + .../assets/javascripts/chart/chartAction.js | 93 +- .../assets/javascripts/chart/chartReducer.js | 59 +- .../dashboard/components/GridCell.jsx | 4 +- .../dashboard/components/GridLayout.jsx | 2 + .../dashboard/components/SliceHeader.jsx | 22 + .../components/controls/AnnotationLayer.jsx | 561 ++++++++ .../controls/AnnotationLayerControl.jsx | 173 +++ .../explore/components/controls/index.js | 2 + .../javascripts/explore/exploreUtils.js | 18 +- superset/assets/javascripts/explore/index.jsx | 3 +- superset/assets/javascripts/explore/main.css | 1 + .../javascripts/explore/stores/controls.jsx | 26 +- .../javascripts/explore/stores/visTypes.js | 2 +- .../javascripts/modules/AnnotationTypes.js | 31 + .../assets/javascripts/modules/superset.js | 293 +++++ superset/assets/package.json | 1 + .../javascripts/explore/chartActions_spec.js | 10 +- superset/assets/stylesheets/dashboard.css | 8 + superset/assets/stylesheets/superset.less | 2 +- superset/assets/visualizations/main.js | 76 +- superset/assets/visualizations/nvd3_vis.css | 29 + superset/assets/visualizations/nvd3_vis.js | 296 ++++- superset/models/annotations.py | 1 + superset/views/core.py | 115 +- superset/viz.py | 17 +- 28 files changed, 2557 insertions(+), 429 deletions(-) create mode 100644 superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx create mode 100644 superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx create mode 100644 superset/assets/javascripts/modules/AnnotationTypes.js create mode 100644 superset/assets/javascripts/modules/superset.js diff --git a/superset/assets/backendSync.json b/superset/assets/backendSync.json index 71e7130328492..ba91b47cde46c 100644 --- a/superset/assets/backendSync.json +++ b/superset/assets/backendSync.json @@ -1,175 +1,15 @@ { "controls": { "datasource": { - "type": "SelectControl", + "type": "DatasourceControl", "label": "Datasource", - "isLoading": true, - "clearable": false, "default": null, - "description": "" + "description": null }, "viz_type": { - "type": "SelectControl", + "type": "VizTypeControl", "label": "Visualization Type", - "clearable": false, "default": "table", - "choices": [ - [ - "dist_bar", - "Distribution - Bar Chart", - "/static/assets/images/viz_thumbnails/dist_bar.png" - ], - [ - "pie", - "Pie Chart", - "/static/assets/images/viz_thumbnails/pie.png" - ], - [ - "line", - "Time Series - Line Chart", - "/static/assets/images/viz_thumbnails/line.png" - ], - [ - "dual_line", - "Time Series - Dual Axis Line Chart", - "/static/assets/images/viz_thumbnails/dual_line.png" - ], - [ - "bar", - "Time Series - Bar Chart", - "/static/assets/images/viz_thumbnails/bar.png" - ], - [ - "compare", - "Time Series - Percent Change", - "/static/assets/images/viz_thumbnails/compare.png" - ], - [ - "area", - "Time Series - Stacked", - "/static/assets/images/viz_thumbnails/area.png" - ], - [ - "table", - "Table View", - "/static/assets/images/viz_thumbnails/table.png" - ], - [ - "markup", - "Markup", - "/static/assets/images/viz_thumbnails/markup.png" - ], - [ - "pivot_table", - "Pivot Table", - "/static/assets/images/viz_thumbnails/pivot_table.png" - ], - [ - "separator", - "Separator", - "/static/assets/images/viz_thumbnails/separator.png" - ], - [ - "word_cloud", - "Word Cloud", - "/static/assets/images/viz_thumbnails/word_cloud.png" - ], - [ - "treemap", - "Treemap", - "/static/assets/images/viz_thumbnails/treemap.png" - ], - [ - "cal_heatmap", - "Calendar Heatmap", - "/static/assets/images/viz_thumbnails/cal_heatmap.png" - ], - [ - "box_plot", - "Box Plot", - "/static/assets/images/viz_thumbnails/box_plot.png" - ], - [ - "bubble", - "Bubble Chart", - "/static/assets/images/viz_thumbnails/bubble.png" - ], - [ - "bullet", - "Bullet Chart", - "/static/assets/images/viz_thumbnails/bullet.png" - ], - [ - "big_number", - "Big Number with Trendline", - "/static/assets/images/viz_thumbnails/big_number.png" - ], - [ - "big_number_total", - "Big Number", - "/static/assets/images/viz_thumbnails/big_number_total.png" - ], - [ - "histogram", - "Histogram", - "/static/assets/images/viz_thumbnails/histogram.png" - ], - [ - "sunburst", - "Sunburst", - "/static/assets/images/viz_thumbnails/sunburst.png" - ], - [ - "sankey", - "Sankey", - "/static/assets/images/viz_thumbnails/sankey.png" - ], - [ - "directed_force", - "Directed Force Layout", - "/static/assets/images/viz_thumbnails/directed_force.png" - ], - [ - "country_map", - "Country Map", - "/static/assets/images/viz_thumbnails/country_map.png" - ], - [ - "world_map", - "World Map", - "/static/assets/images/viz_thumbnails/world_map.png" - ], - [ - "filter_box", - "Filter Box", - "/static/assets/images/viz_thumbnails/filter_box.png" - ], - [ - "iframe", - "iFrame", - "/static/assets/images/viz_thumbnails/iframe.png" - ], - [ - "para", - "Parallel Coordinates", - "/static/assets/images/viz_thumbnails/para.png" - ], - [ - "heatmap", - "Heatmap", - "/static/assets/images/viz_thumbnails/heatmap.png" - ], - [ - "horizon", - "Horizon", - "/static/assets/images/viz_thumbnails/horizon.png" - ], - [ - "mapbox", - "Mapbox", - "/static/assets/images/viz_thumbnails/mapbox.png" - ] - ], "description": "The type of visualization to display" }, "metrics": { @@ -179,8 +19,26 @@ "validators": [ null ], + "valueKey": "metric_name", "description": "One or many metrics to display" }, + "percent_metrics": { + "type": "SelectControl", + "multi": true, + "label": "Percentage Metrics", + "valueKey": "metric_name", + "description": "Metrics for which percentage of total are to be displayed" + }, + "y_axis_bounds": { + "type": "BoundsControl", + "label": "Y Axis Bounds", + "renderTrigger": true, + "default": [ + null, + null + ], + "description": "Bounds for the Y axis. When left empty, the bounds are dynamically defined based on the min/max of the data. Note that this feature will only expand the axis range. It won't narrow the data's extent." + }, "order_by_cols": { "type": "SelectControl", "multi": true, @@ -188,18 +46,38 @@ "default": [], "description": "One or many metrics to display" }, + "color_picker": { + "label": "Fixed Color", + "description": "Use this to define a static color for all circles", + "type": "ColorPickerControl", + "default": { + "r": 0, + "g": 122, + "b": 135, + "a": 1 + }, + "renderTrigger": true + }, "metric": { "type": "SelectControl", "label": "Metric", "clearable": false, - "description": "Choose the metric" + "description": "Choose the metric", + "validators": [ + null + ], + "valueKey": "metric_name" }, "metric_2": { "type": "SelectControl", "label": "Right Axis Metric", - "choices": [], - "default": [], - "description": "Choose a metric for right axis" + "default": null, + "validators": [ + null + ], + "clearable": true, + "description": "Choose a metric for right axis", + "valueKey": "metric_name" }, "stacked_style": { "type": "SelectControl", @@ -221,8 +99,56 @@ "default": "stack", "description": "" }, - "linear_color_scheme": { + "sort_x_axis": { "type": "SelectControl", + "label": "Sort X Axis", + "choices": [ + [ + "alpha_asc", + "Axis ascending" + ], + [ + "alpha_desc", + "Axis descending" + ], + [ + "value_asc", + "sum(value) ascending" + ], + [ + "value_desc", + "sum(value) descending" + ] + ], + "clearable": false, + "default": "alpha_asc" + }, + "sort_y_axis": { + "type": "SelectControl", + "label": "Sort Y Axis", + "choices": [ + [ + "alpha_asc", + "Axis ascending" + ], + [ + "alpha_desc", + "Axis descending" + ], + [ + "value_asc", + "sum(value) ascending" + ], + [ + "value_desc", + "sum(value) descending" + ] + ], + "clearable": false, + "default": "alpha_asc" + }, + "linear_color_scheme": { + "type": "ColorSchemeControl", "label": "Linear Color Scheme", "choices": [ [ @@ -240,10 +166,54 @@ [ "black_white", "black/white" + ], + [ + "dark_blue", + "light/dark blue" + ], + [ + "pink_grey", + "pink/white/grey" ] ], "default": "blue_white_yellow", - "description": "" + "clearable": false, + "description": "", + "renderTrigger": true, + "schemes": { + "blue_white_yellow": [ + "#00d1c1", + "white", + "#ffb400" + ], + "fire": [ + "white", + "yellow", + "red", + "black" + ], + "white_black": [ + "white", + "black" + ], + "black_white": [ + "black", + "white" + ], + "dark_blue": [ + "#EBF5F8", + "#6BB1CC", + "#357E9B", + "#1B4150", + "#092935" + ], + "pink_grey": [ + "#E70B81", + "#FAFAFA", + "#666666" + ] + }, + "isLinear": true }, "normalize_across": { "type": "SelectControl", @@ -288,6 +258,7 @@ "canvas_image_rendering": { "type": "SelectControl", "label": "Rendering", + "renderTrigger": true, "choices": [ [ "pixelated", @@ -723,6 +694,13 @@ "description": "Whether to include the time granularity as defined in the time section", "default": false }, + "show_perc": { + "type": "CheckboxControl", + "label": "Show percentage", + "renderTrigger": true, + "description": "Whether to include the percentage in the tooltip", + "default": true + }, "bar_stacked": { "type": "CheckboxControl", "label": "Stacked Bars", @@ -730,6 +708,13 @@ "default": false, "description": null }, + "pivot_margins": { + "type": "CheckboxControl", + "label": "Show totals", + "renderTrigger": false, + "default": true, + "description": "Display total row/column" + }, "show_markers": { "type": "CheckboxControl", "label": "Show Markers", @@ -785,29 +770,21 @@ }, "select_country": { "type": "SelectControl", - "label": "Country Name Type", + "label": "Country Name", "default": "France", "choices": [ - [ - "Algeria", - "Algeria" - ], [ "Belgium", "Belgium" ], [ - "Brasil", - "Brasil" + "Brazil", + "Brazil" ], [ "China", "China" ], - [ - "Germany", - "Germany" - ], [ "Egypt", "Egypt" @@ -816,6 +793,10 @@ "France", "France" ], + [ + "Germany", + "Germany" + ], [ "Italy", "Italy" @@ -825,8 +806,8 @@ "Morocco" ], [ - "Nederlanden", - "Nederlanden" + "Netherlands", + "Netherlands" ], [ "Russia", @@ -844,6 +825,10 @@ "Uk", "Uk" ], + [ + "Ukraine", + "Ukraine" + ], [ "Usa", "Usa" @@ -875,19 +860,66 @@ ], "description": "The country code standard that Superset should expect to find in the [country] column" }, + "freq": { + "type": "SelectControl", + "label": "Frequency", + "default": "W-MON", + "freeForm": true, + "clearable": false, + "choices": [ + [ + "AS", + "Year (freq=AS)" + ], + [ + "52W-MON", + "52 weeks starting Monday (freq=52W-MON)" + ], + [ + "W-SUN", + "1 week starting Sunday (freq=W-SUN)" + ], + [ + "W-MON", + "1 week starting Monday (freq=W-MON)" + ], + [ + "D", + "Day (freq=D)" + ], + [ + "4W-MON", + "4 weeks (freq=4W-MON)" + ] + ], + "description": "The periodicity over which to pivot time. Users can provide\n \"Pandas\" offset alias.\n Click on the info bubble for more details on accepted \"freq\" expressions." + }, "groupby": { "type": "SelectControl", "multi": true, "label": "Group by", "default": [], - "description": "One or many controls to group by" + "includeTime": false, + "description": "One or many controls to group by", + "valueKey": "column_name" + }, + "dimension": { + "type": "SelectControl", + "multi": false, + "label": "Dimension", + "default": null, + "includeTime": false, + "description": "Select a dimension", + "valueKey": "column_name" }, "columns": { "type": "SelectControl", "multi": true, "label": "Columns", "default": [], - "description": "One or many controls to pivot as columns" + "includeTime": false, + "description": "One or many controls to pivot as columns", + "valueKey": "column_name" }, "all_columns": { "type": "SelectControl", @@ -896,6 +928,24 @@ "default": [], "description": "Columns to display" }, + "longitude": { + "type": "SelectControl", + "label": "Longitude", + "default": 1, + "validators": [ + null + ], + "description": "Select the longitude column" + }, + "latitude": { + "type": "SelectControl", + "label": "Latitude", + "default": 1, + "validators": [ + null + ], + "description": "Select the latitude column" + }, "all_columns_x": { "type": "SelectControl", "label": "X", @@ -960,7 +1010,46 @@ ] ], "default": "auto", - "description": "Bottom marging, in pixels, allowing for more room for axis labels" + "renderTrigger": true, + "description": "Bottom margin, in pixels, allowing for more room for axis labels" + }, + "left_margin": { + "type": "SelectControl", + "freeForm": true, + "label": "Left Margin", + "choices": [ + [ + "auto", + "auto" + ], + [ + 50, + "50" + ], + [ + 75, + "75" + ], + [ + 100, + "100" + ], + [ + 125, + "125" + ], + [ + 150, + "150" + ], + [ + 200, + "200" + ] + ], + "default": "auto", + "renderTrigger": true, + "description": "Left margin, in pixels, allowing for more room for axis labels" }, "granularity": { "type": "SelectControl", @@ -1172,7 +1261,9 @@ "granularity_sqla": { "type": "SelectControl", "label": "Time Column", - "description": "The time column for the visualization. Note that you can define arbitrary expression that return a DATETIME column in the table or. Also note that the filter below is applied against this column or expression" + "description": "The time column for the visualization. Note that you can define arbitrary expression that return a DATETIME column in the table. Also note that the filter below is applied against this column or expression", + "clearable": false, + "valueKey": "column_name" }, "time_grain_sqla": { "type": "SelectControl", @@ -1263,77 +1354,16 @@ "description": "Pandas resample fill method" }, "since": { - "type": "SelectControl", + "type": "DateFilterControl", "freeForm": true, "label": "Since", - "default": "7 days ago", - "choices": [ - [ - "1 hour ago", - "1 hour ago" - ], - [ - "12 hours ago", - "12 hours ago" - ], - [ - "1 day ago", - "1 day ago" - ], - [ - "7 days ago", - "7 days ago" - ], - [ - "28 days ago", - "28 days ago" - ], - [ - "90 days ago", - "90 days ago" - ], - [ - "1 year ago", - "1 year ago" - ], - [ - "100 year ago", - "100 year ago" - ] - ], - "description": "Timestamp from filter. This supports free form typing and natural language as in `1 day ago`, `28 days` or `3 years`" + "default": "7 days ago" }, "until": { - "type": "SelectControl", + "type": "DateFilterControl", "freeForm": true, "label": "Until", - "default": "now", - "choices": [ - [ - "now", - "now" - ], - [ - "1 day ago", - "1 day ago" - ], - [ - "7 days ago", - "7 days ago" - ], - [ - "28 days ago", - "28 days ago" - ], - [ - "90 days ago", - "90 days ago" - ], - [ - "1 year ago", - "1 year ago" - ] - ] + "default": "now" }, "max_bubble_size": { "type": "SelectControl", @@ -1441,6 +1471,9 @@ "type": "SelectControl", "freeForm": true, "label": "Row limit", + "validators": [ + null + ], "default": null, "choices": [ [ @@ -1485,6 +1518,9 @@ "type": "SelectControl", "freeForm": true, "label": "Series limit", + "validators": [ + null + ], "choices": [ [ 0, @@ -1524,6 +1560,12 @@ "default": null, "description": "Metric used to define the top series" }, + "order_desc": { + "type": "CheckboxControl", + "label": "Sort Descending", + "default": true, + "description": "Whether to sort descending or ascending" + }, "rolling_type": { "type": "SelectControl", "label": "Rolling", @@ -1552,12 +1594,33 @@ ], "description": "Defines a rolling window function to apply, works along with the [Periods] text box" }, + "multiplier": { + "type": "TextControl", + "label": "Multiplier", + "isFloat": true, + "default": 1, + "description": "Factor to multiply the metric by" + }, "rolling_periods": { "type": "TextControl", "label": "Periods", "isInt": true, "description": "Defines the size of the rolling window function, relative to the time granularity selected" }, + "grid_size": { + "type": "TextControl", + "label": "Grid Size", + "renderTrigger": true, + "default": 20, + "isInt": true, + "description": "Defines the grid size in pixels" + }, + "min_periods": { + "type": "TextControl", + "label": "Min Periods", + "isInt": true, + "description": "The minimum number of rolling periods required to show a value. For instance if you do a cumulative sum on 7 days you may want your \"Min Period\" to be 7, so that all data points shown are the total of 7 periods. This will hide the \"ramp up\" taking place over the first 7 periods" + }, "series": { "type": "SelectControl", "label": "Series", @@ -1568,24 +1631,39 @@ "type": "SelectControl", "label": "Entity", "default": null, - "description": "This define the element to be plotted on the chart" + "validators": [ + null + ], + "description": "This defines the element to be plotted on the chart" }, "x": { "type": "SelectControl", "label": "X Axis", + "description": "Metric assigned to the [X] axis", "default": null, - "description": "Metric assigned to the [X] axis" + "validators": [ + null + ], + "valueKey": "metric_name" }, "y": { "type": "SelectControl", "label": "Y Axis", "default": null, - "description": "Metric assigned to the [Y] axis" + "validators": [ + null + ], + "description": "Metric assigned to the [Y] axis", + "valueKey": "metric_name" }, "size": { "type": "SelectControl", "label": "Bubble Size", - "default": null + "default": null, + "validators": [ + null + ], + "valueKey": "metric_name" }, "url": { "type": "TextControl", @@ -1632,7 +1710,11 @@ "type": "SelectControl", "freeForm": true, "label": "Table Timestamp Format", - "default": "smart_date", + "default": "%Y-%m-%d %H:%M:%S", + "validators": [ + null + ], + "clearable": false, "choices": [ [ "smart_date", @@ -1746,7 +1828,41 @@ "x_axis_format": { "type": "SelectControl", "freeForm": true, - "label": "X axis format", + "label": "X Axis Format", + "renderTrigger": true, + "default": ".3s", + "choices": [ + [ + ".3s", + ".3s | 12.3k" + ], + [ + ".3%", + ".3% | 1234543.210%" + ], + [ + ".4r", + ".4r | 12350" + ], + [ + ".3f", + ".3f | 12345.432" + ], + [ + "+,", + "+, | +12,345.4321" + ], + [ + "$,.2f", + "$,.2f | $12,345.43" + ] + ], + "description": "D3 format syntax: https://github.com/d3/d3-format" + }, + "x_axis_time_format": { + "type": "SelectControl", + "freeForm": true, + "label": "X Axis Format", "renderTrigger": true, "default": "smart_date", "choices": [ @@ -1776,7 +1892,7 @@ "y_axis_format": { "type": "SelectControl", "freeForm": true, - "label": "Y axis format", + "label": "Y Axis Format", "renderTrigger": true, "default": ".3s", "choices": [ @@ -1810,7 +1926,7 @@ "y_axis_2_format": { "type": "SelectControl", "freeForm": true, - "label": "Right axis format", + "label": "Right Axis Format", "default": ".3s", "choices": [ [ @@ -1840,9 +1956,40 @@ ], "description": "D3 format syntax: https://github.com/d3/d3-format" }, + "date_time_format": { + "type": "SelectControl", + "freeForm": true, + "label": "Date Time Format", + "renderTrigger": true, + "default": "smart_date", + "choices": [ + [ + "smart_date", + "Adaptative formating" + ], + [ + "%m/%d/%Y", + "%m/%d/%Y | 01/14/2019" + ], + [ + "%Y-%m-%d", + "%Y-%m-%d | 2019-01-14" + ], + [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S | 2019-01-14 01:32:10" + ], + [ + "%H:%M:%S", + "%H:%M:%S | 01:32:10" + ] + ], + "description": "D3 format syntax: https://github.com/d3/d3-format" + }, "markup_type": { "type": "SelectControl", "label": "Markup Type", + "clearable": false, "choices": [ [ "markdown", @@ -1854,6 +2001,9 @@ ] ], "default": "markdown", + "validators": [ + null + ], "description": "Pick your favorite markup language" }, "rotation": { @@ -1925,6 +2075,14 @@ [ "percent", "Percentage" + ], + [ + "key_value", + "Category and Value" + ], + [ + "key_percent", + "Category and Percentage" ] ], "description": "What should be shown on the label?" @@ -1993,6 +2151,13 @@ "default": true, "description": "Whether to apply filters as they change, or wait forusers to hit an [Apply] button" }, + "extruded": { + "type": "CheckboxControl", + "label": "Extruded", + "renderTrigger": true, + "default": true, + "description": "Whether to make the grid 3D" + }, "show_brush": { "type": "CheckboxControl", "label": "Range Filter", @@ -2006,6 +2171,30 @@ "default": false, "description": "Whether to include a time filter" }, + "show_sqla_time_granularity": { + "type": "CheckboxControl", + "label": "Show SQL Granularity Dropdown", + "default": false, + "description": "Check to include SQL Granularity dropdown" + }, + "show_sqla_time_column": { + "type": "CheckboxControl", + "label": "Show SQL Time Column", + "default": false, + "description": "Check to include Time Column dropdown" + }, + "show_druid_time_granularity": { + "type": "CheckboxControl", + "label": "Show Druid Granularity Dropdown", + "default": false, + "description": "Check to include Druid Granularity dropdown" + }, + "show_druid_time_origin": { + "type": "CheckboxControl", + "label": "Show Druid Time Origin", + "default": false, + "description": "Check to include Time Origin dropdown" + }, "show_datatable": { "type": "CheckboxControl", "label": "Data Table", @@ -2039,6 +2228,13 @@ "default": true, "description": "Whether to display the legend (toggles)" }, + "show_values": { + "type": "CheckboxControl", + "label": "Show Values", + "renderTrigger": true, + "default": false, + "description": "Whether to display the numerical values within the cells" + }, "x_axis_showminmax": { "type": "CheckboxControl", "label": "X bounds", @@ -2046,19 +2242,19 @@ "default": true, "description": "Whether to display the min and max values of the X axis" }, - "rich_tooltip": { + "y_axis_showminmax": { "type": "CheckboxControl", - "label": "Rich Tooltip", + "label": "Y bounds", "renderTrigger": true, "default": true, - "description": "The rich tooltip shows a list of all series for that point in time" + "description": "Whether to display the min and max values of the Y axis" }, - "y_axis_zero": { + "rich_tooltip": { "type": "CheckboxControl", - "label": "Y Axis Zero", - "default": false, + "label": "Rich Tooltip", "renderTrigger": true, - "description": "Force the Y axis to start at 0 instead of the minimum value" + "default": true, + "description": "The rich tooltip shows a list of all series for that point in time" }, "y_log_scale": { "type": "CheckboxControl", @@ -2074,16 +2270,25 @@ "renderTrigger": true, "description": "Use a log scale for the X axis" }, + "log_scale": { + "type": "CheckboxControl", + "label": "Log Scale", + "default": false, + "renderTrigger": true, + "description": "Use a log scale" + }, "donut": { "type": "CheckboxControl", "label": "Donut", "default": false, + "renderTrigger": true, "description": "Do you want a donut or a pie?" }, "labels_outside": { "type": "CheckboxControl", "label": "Put labels outside", "default": true, + "renderTrigger": true, "description": "Put the labels outside the pie?" }, "contribution": { @@ -2140,6 +2345,7 @@ "mapbox_style": { "type": "SelectControl", "label": "Map Style", + "renderTrigger": true, "choices": [ [ "mapbox://styles/mapbox/streets-v9", @@ -2214,6 +2420,11 @@ ], "description": "The radius (in pixels) the algorithm uses to define a cluster. Choose 0 to turn off clustering, but beware that a large number of points (>1000) will cause lag." }, + "point_radius_fixed": { + "type": "FixedOrMetricControl", + "label": "Point Size", + "description": "Fixed point radius" + }, "point_radius": { "type": "SelectControl", "label": "Point Radius", @@ -2240,6 +2451,39 @@ ], "description": "The unit of measure for the specified point radius" }, + "point_unit": { + "type": "SelectControl", + "label": "Point Unit", + "default": "square_m", + "clearable": false, + "choices": [ + [ + "square_m", + "Square meters" + ], + [ + "square_km", + "Square kilometers" + ], + [ + "square_miles", + "Square miles" + ], + [ + "radius_m", + "Radius in meters" + ], + [ + "radius_km", + "Radius in kilometers" + ], + [ + "radius_miles", + "Radius in miles" + ] + ], + "description": "The unit of measure for the specified point radius" + }, "global_opacity": { "type": "TextControl", "label": "Opacity", @@ -2247,6 +2491,19 @@ "isFloat": true, "description": "Opacity of all clusters, points, and labels. Between 0 and 1." }, + "viewport": { + "type": "ViewportControl", + "label": "Viewport", + "renderTrigger": true, + "description": "Parameters related to the view and perspective on the map", + "default": { + "longitude": 6.85236157047845, + "latitude": 31.222656842808707, + "zoom": 1, + "bearing": 0, + "pitch": 0 + } + }, "viewport_zoom": { "type": "TextControl", "label": "Zoom", @@ -2310,6 +2567,17 @@ ], "description": "The color for points and clusters in RGB" }, + "color": { + "type": "ColorPickerControl", + "label": "Color", + "default": { + "r": 0, + "g": 122, + "b": 135, + "a": 1 + }, + "description": "Pick a color" + }, "ranges": { "type": "TextControl", "label": "Ranges", @@ -2352,6 +2620,13 @@ "default": [], "description": "" }, + "annotation_layers": { + "type": "AnnotationLayerControl", + "label": "", + "default": [], + "description": "Annotation Layers", + "renderTrigger": true + }, "having_filters": { "type": "FilterControl", "label": "", @@ -2369,6 +2644,334 @@ "label": "Cache Timeout (seconds)", "hidden": true, "description": "The number of seconds before expiring the cache" + }, + "order_by_entity": { + "type": "CheckboxControl", + "label": "Order by entity id", + "description": "Important! Select this if the table is not already sorted by entity id, else there is no guarantee that all events for each entity are returned.", + "default": true + }, + "min_leaf_node_event_count": { + "type": "SelectControl", + "freeForm": false, + "label": "Minimum leaf node event count", + "default": 1, + "choices": [ + [ + 1, + "1" + ], + [ + 2, + "2" + ], + [ + 3, + "3" + ], + [ + 4, + "4" + ], + [ + 5, + "5" + ], + [ + 6, + "6" + ], + [ + 7, + "7" + ], + [ + 8, + "8" + ], + [ + 9, + "9" + ], + [ + 10, + "10" + ] + ], + "description": "Leaf nodes that represent fewer than this number of events will be initially hidden in the visualization" + }, + "color_scheme": { + "type": "ColorSchemeControl", + "label": "Color Scheme", + "default": "bnbColors", + "renderTrigger": true, + "choices": [ + [ + "bnbColors", + "bnbColors" + ], + [ + "d3Category10", + "d3Category10" + ], + [ + "d3Category20", + "d3Category20" + ], + [ + "d3Category20b", + "d3Category20b" + ], + [ + "d3Category20c", + "d3Category20c" + ], + [ + "googleCategory10c", + "googleCategory10c" + ], + [ + "googleCategory20c", + "googleCategory20c" + ] + ], + "description": "The color scheme for rendering chart", + "schemes": { + "bnbColors": [ + "#ff5a5f", + "#7b0051", + "#007A87", + "#00d1c1", + "#8ce071", + "#ffb400", + "#b4a76c", + "#ff8083", + "#cc0086", + "#00a1b3", + "#00ffeb", + "#bbedab", + "#ffd266", + "#cbc29a", + "#ff3339", + "#ff1ab1", + "#005c66", + "#00b3a5", + "#55d12e", + "#b37e00", + "#988b4e" + ], + "d3Category10": [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf" + ], + "d3Category20": [ + "#1f77b4", + "#aec7e8", + "#ff7f0e", + "#ffbb78", + "#2ca02c", + "#98df8a", + "#d62728", + "#ff9896", + "#9467bd", + "#c5b0d5", + "#8c564b", + "#c49c94", + "#e377c2", + "#f7b6d2", + "#7f7f7f", + "#c7c7c7", + "#bcbd22", + "#dbdb8d", + "#17becf", + "#9edae5" + ], + "d3Category20b": [ + "#393b79", + "#5254a3", + "#6b6ecf", + "#9c9ede", + "#637939", + "#8ca252", + "#b5cf6b", + "#cedb9c", + "#8c6d31", + "#bd9e39", + "#e7ba52", + "#e7cb94", + "#843c39", + "#ad494a", + "#d6616b", + "#e7969c", + "#7b4173", + "#a55194", + "#ce6dbd", + "#de9ed6" + ], + "d3Category20c": [ + "#3182bd", + "#6baed6", + "#9ecae1", + "#c6dbef", + "#e6550d", + "#fd8d3c", + "#fdae6b", + "#fdd0a2", + "#31a354", + "#74c476", + "#a1d99b", + "#c7e9c0", + "#756bb1", + "#9e9ac8", + "#bcbddc", + "#dadaeb", + "#636363", + "#969696", + "#bdbdbd", + "#d9d9d9" + ], + "googleCategory10c": [ + "#3366cc", + "#dc3912", + "#ff9900", + "#109618", + "#990099", + "#0099c6", + "#dd4477", + "#66aa00", + "#b82e2e", + "#316395" + ], + "googleCategory20c": [ + "#3366cc", + "#dc3912", + "#ff9900", + "#109618", + "#990099", + "#0099c6", + "#dd4477", + "#66aa00", + "#b82e2e", + "#316395", + "#994499", + "#22aa99", + "#aaaa11", + "#6633cc", + "#e67300", + "#8b0707", + "#651067", + "#329262", + "#5574a6", + "#3b3eac" + ] + } + }, + "significance_level": { + "type": "TextControl", + "label": "Significance Level", + "default": 0.05, + "description": "Threshold alpha level for determining significance" + }, + "pvalue_precision": { + "type": "TextControl", + "label": "p-value precision", + "default": 6, + "description": "Number of decimal places with which to display p-values" + }, + "liftvalue_precision": { + "type": "TextControl", + "label": "Lift percent precision", + "default": 4, + "description": "Number of decimal places with which to display lift values" + }, + "column_collection": { + "type": "CollectionControl", + "label": "Time Series Columns", + "validators": [ + null + ], + "controlName": "TimeSeriesColumnControl" + }, + "time_series_option": { + "type": "SelectControl", + "label": "Options", + "validators": [ + null + ], + "default": "not_time", + "valueKey": "value", + "options": [ + { + "label": "Not Time Series", + "value": "not_time", + "description": "Ignore time" + }, + { + "label": "Time Series", + "value": "time_series", + "description": "Standard time series" + }, + { + "label": "Aggregate Mean", + "value": "agg_mean", + "description": "Mean of values over specified period" + }, + { + "label": "Aggregate Sum", + "value": "agg_sum", + "description": "Sum of values over specified period" + }, + { + "label": "Difference", + "value": "point_diff", + "description": "Metric change in value from `since` to `until`" + }, + { + "label": "Percent Change", + "value": "point_percent", + "description": "Metric percent change in value from `since` to `until`" + }, + { + "label": "Factor", + "value": "point_factor", + "description": "Metric factor change from `since` to `until`" + }, + { + "label": "Advanced Analytics", + "value": "adv_anal", + "description": "Use the Advanced Analytics options below" + } + ], + "description": "Settings for time series" + }, + "equal_date_size": { + "type": "CheckboxControl", + "label": "Equal Date Sizes", + "default": true, + "renderTrigger": true, + "description": "Check to force date partitions to have the same height" + }, + "partition_limit": { + "type": "TextControl", + "label": "Partition Limit", + "isInt": true, + "default": "5", + "description": "The maximum number of subdivisions of each group; lower values are pruned first" + }, + "partition_threshold": { + "type": "TextControl", + "label": "Partition Threshold", + "isFloat": true, + "default": "0.05", + "description": "Partitions whose height to parent height proportions are below this value are pruned" } } } \ No newline at end of file diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx index a4e3dd2abddfe..3dd0355ab08fc 100644 --- a/superset/assets/javascripts/chart/Chart.jsx +++ b/superset/assets/javascripts/chart/Chart.jsx @@ -10,6 +10,7 @@ import StackTraceMessage from '../components/StackTraceMessage'; import visMap from '../../visualizations/main'; const propTypes = { + annotationData: PropTypes.object, actions: PropTypes.object, chartKey: PropTypes.string.isRequired, containerId: PropTypes.string.isRequired, @@ -47,8 +48,8 @@ const defaultProps = { class Chart extends React.PureComponent { constructor(props) { super(props); - // these properties are used by visualizations + this.annotationData = props.annotationData; this.containerId = props.containerId; this.selector = `#${this.containerId}`; this.formData = props.formData; @@ -71,6 +72,7 @@ class Chart extends React.PureComponent { } componentWillReceiveProps(nextProps) { + this.annotationData = nextProps.annotationData; this.containerId = nextProps.containerId; this.selector = `#${this.containerId}`; this.formData = nextProps.formData; @@ -82,6 +84,7 @@ class Chart extends React.PureComponent { this.props.queryResponse && this.props.chartStatus === 'success' && !this.props.queryResponse.error && ( + prevProps.annotationData !== this.props.annotationData || prevProps.queryResponse !== this.props.queryResponse || prevProps.height !== this.props.height || prevProps.width !== this.props.width || diff --git a/superset/assets/javascripts/chart/ChartContainer.jsx b/superset/assets/javascripts/chart/ChartContainer.jsx index d517677ec4d41..b731412fc5ff7 100644 --- a/superset/assets/javascripts/chart/ChartContainer.jsx +++ b/superset/assets/javascripts/chart/ChartContainer.jsx @@ -7,6 +7,7 @@ import Chart from './Chart'; function mapStateToProps({ charts }, ownProps) { const chart = charts[ownProps.chartKey]; return { + annotationData: chart.annotationData, chartAlert: chart.chartAlert, chartStatus: chart.chartStatus, chartUpdateEndTime: chart.chartUpdateEndTime, diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js index 17205a41a3cc6..fa8fc0c02c16f 100644 --- a/superset/assets/javascripts/chart/chartAction.js +++ b/superset/assets/javascripts/chart/chartAction.js @@ -1,5 +1,5 @@ -import { getExploreUrl } from '../explore/exploreUtils'; -import { t } from '../locales'; +import { getExploreUrl, getSliceJsonUrl } from '../explore/exploreUtils'; +import { requiresQuery } from '../modules/AnnotationTypes'; const $ = window.$ = require('jquery'); @@ -41,6 +41,54 @@ export function removeChart(key) { return { type: REMOVE_CHART, key }; } +export const ANNOTATION_QUERY_SUCCESS = 'ANNOTATION_QUERY_SUCCESS'; +export function annotationQuerySuccess(annotation, queryResponse, key) { + return { type: ANNOTATION_QUERY_SUCCESS, annotation, queryResponse, key }; +} + +export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED'; +export function annotationQueryStarted(annotation, queryRequest, key) { + return {type: ANNOTATION_QUERY_STARTED, annotation, queryRequest, key }; +} + +export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED'; +export function annotationQueryFailed(annotation, queryResponse, key) { + return { type: ANNOTATION_QUERY_FAILED, annotation, queryResponse, key }; +} + +export function runAnnotationQuery(annotation, timeout = 60, formData = null, key) { + return function (dispatch, getState) { + const sliceKey = key || Object.keys(getState().charts)[0]; + const fd = formData || getState().charts[sliceKey].latestQueryFormData; + + if (!requiresQuery(annotation.annotationType)) { + return Promise.resolve(); + } + const sliceFormData = Object.keys(annotation.overrides) + .reduce((d, k) => ({ + ...d, + [k]: annotation.overrides[k] || fd[k], + }), {}); + const url = getSliceJsonUrl(annotation.value, sliceFormData, 'json'); + const queryRequest = $.ajax({ + url, + dataType: 'json', + timeout: timeout * 1000, + }); + dispatch(annotationQueryStarted(annotation, queryRequest, sliceKey)); + return queryRequest + .then(queryResponse => dispatch(annotationQuerySuccess(annotation, queryResponse, sliceKey))) + .catch((err) => { + if (err.statusText === 'timeout') { + dispatch(annotationQueryFailed(annotation, { error: 'Query Timeout' }, sliceKey)); + } else if (err.statusText !== 'abort') { + dispatch(annotationQueryFailed(annotation, err.responseJSON, sliceKey)); + } + }); + // .then(() => dispatch(renderTriggered(true, sliceKey))); + }; +} + export const TRIGGER_QUERY = 'TRIGGER_QUERY'; export function triggerQuery(value = true, key) { return { type: TRIGGER_QUERY, value, key }; @@ -60,32 +108,23 @@ export function runQuery(formData, force = false, timeout = 60, key) { url, dataType: 'json', timeout: timeout * 1000, - success: (queryResponse => - dispatch(chartUpdateSucceeded(queryResponse, key)) - ), - error: ((xhr) => { - if (xhr.statusText === 'timeout') { - dispatch(chartUpdateTimeout(xhr.statusText, timeout, key)); - } else { - let error = ''; - if (!xhr.responseText) { - const status = xhr.status; - if (status === 0) { - // This may happen when the worker in gunicorn times out - error += ( - t('The server could not be reached. You may want to ' + - 'verify your connection and try again.')); - } else { - error += (t('An unknown error occurred. (Status: %s )', status)); - } - } - const errorResponse = Object.assign({}, xhr.responseJSON, error); - dispatch(chartUpdateFailed(errorResponse, key)); - } - }), }); - dispatch(chartUpdateStarted(queryRequest, key)); - dispatch(triggerQuery(false, key)); + const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, key))) + .then(() => queryRequest) + .then(queryResponse => dispatch(chartUpdateSucceeded(queryResponse, key))) + .catch((err) => { + if (err.statusText === 'timeout') { + dispatch(chartUpdateTimeout(err.statusText, timeout, key)); + } else if (err.statusText !== 'abort') { + dispatch(chartUpdateFailed(err.responseJSON, key)); + } + }); + const annotationLayers = formData.annotation_layers || []; + return Promise.all([ + queryPromise, + dispatch(triggerQuery(false, key)), + ...annotationLayers.map(x => dispatch(runAnnotationQuery(x, timeout, formData, key))), + ]); }; } diff --git a/superset/assets/javascripts/chart/chartReducer.js b/superset/assets/javascripts/chart/chartReducer.js index ade8c5bf68f03..3cc9e5e6dfc3f 100644 --- a/superset/assets/javascripts/chart/chartReducer.js +++ b/superset/assets/javascripts/chart/chartReducer.js @@ -65,12 +65,12 @@ export default function chartReducer(charts = {}, action) { return { ...state, chartStatus: 'failed', chartAlert: ( - `${t('Query timeout')} - ` + - t(`visualization queries are set to timeout at ${action.timeout} seconds. `) + - t('Perhaps your data has grown, your database is under unusual load, ' + - 'or you are simply querying a data source that is too large ' + - 'to be processed within the timeout range. ' + - 'If that is the case, we recommend that you summarize your data further.')), + `${t('Query timeout')} - ` + + t(`visualization queries are set to timeout at ${action.timeout} seconds. `) + + t('Perhaps your data has grown, your database is under unusual load, ' + + 'or you are simply querying a data source that is too large ' + + 'to be processed within the timeout range. ' + + 'If that is the case, we recommend that you summarize your data further.')), }; }, [actions.CHART_UPDATE_FAILED](state) { @@ -87,6 +87,53 @@ export default function chartReducer(charts = {}, action) { [actions.RENDER_TRIGGERED](state) { return { ...state, lastRendered: action.value }; }, + [actions.ANNOTATION_QUERY_STARTED](state) { + if (state.annotationQuery && + state.annotationQuery[action.annotation.name]) { + state.annotationQuery[action.annotation.name].abort(); + } + const annotationQuery = { + ...state.annotationQuery, + [action.annotation.name]: action.queryRequest, + }; + return { + ...state, + annotationQuery, + }; + }, + [actions.ANNOTATION_QUERY_SUCCESS](state) { + const annotationData = { + ...state.annotationData, + [action.annotation.name]: action.queryResponse.data, + }; + const annotationError = { ...state.annotationError }; + delete annotationError[action.annotation.name]; + const annotationQuery = { ...state.annotationQuery }; + delete annotationQuery[action.annotation.name]; + return { + ...state, + annotationData, + annotationError, + annotationQuery, + }; + }, + [actions.ANNOTATION_QUERY_FAILED](state) { + const annotationData = { ...state.annotationData }; + delete annotationData[action.annotation.name]; + const annotationError = { + ...state.annotationError, + [action.annotation.name]: action.queryResponse ? + action.queryResponse.error : t('Network error.'), + }; + const annotationQuery = { ...state.annotationQuery }; + delete annotationQuery[action.annotation.name]; + return { + ...state, + annotationData, + annotationError, + annotationQuery, + }; + }, }; /* eslint-disable no-param-reassign */ diff --git a/superset/assets/javascripts/dashboard/components/GridCell.jsx b/superset/assets/javascripts/dashboard/components/GridCell.jsx index 854aea01fe74a..8277dde59a8f2 100644 --- a/superset/assets/javascripts/dashboard/components/GridCell.jsx +++ b/superset/assets/javascripts/dashboard/components/GridCell.jsx @@ -31,6 +31,7 @@ const propTypes = { clearFilter: PropTypes.func, removeFilter: PropTypes.func, editMode: PropTypes.bool, + annotationQuery: PropTypes.object, }; const defaultProps = { @@ -84,7 +85,7 @@ class GridCell extends React.PureComponent { const { exploreChartUrl, exportCSVUrl, isExpanded, isLoading, isCached, cachedDttm, removeSlice, updateSliceName, toggleExpandSlice, forceRefresh, - chartKey, slice, datasource, formData, timeout, + chartKey, slice, datasource, formData, timeout, annotationQuery } = this.props; return (
); }); diff --git a/superset/assets/javascripts/dashboard/components/SliceHeader.jsx b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx index 36107fedf1e0d..738f460226148 100644 --- a/superset/assets/javascripts/dashboard/components/SliceHeader.jsx +++ b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx @@ -19,6 +19,8 @@ const propTypes = { toggleExpandSlice: PropTypes.func, forceRefresh: PropTypes.func, editMode: PropTypes.bool, + annotationQuery: PropTypes.object, + annotationError: PropTypes.object, }; const defaultProps = { @@ -50,6 +52,8 @@ class SliceHeader extends React.PureComponent { const refreshTooltip = isCached ? t('Served from data cached %s . Click to force refresh.', cachedWhen) : t('Force refresh data'); + const annoationsLoading = t('Annotation layers are still loading.'); + const annoationsError = t('One ore more annotation layers failed loading.'); return (
@@ -61,6 +65,24 @@ class SliceHeader extends React.PureComponent { onSaveTitle={this.onSaveTitle} noPermitTooltip={'You don\'t have the rights to alter this dashboard.'} /> + {!!Object.values(this.props.annotationQuery || {}).length && + + + + } + {!!Object.values(this.props.annotationError || {}).length && + + + + }
diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx new file mode 100644 index 0000000000000..ee5af00c18ff6 --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx @@ -0,0 +1,561 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { CompactPicker } from 'react-color'; +import { Button } from 'react-bootstrap'; + +import $ from 'jquery'; +import mathjs from 'mathjs'; + +import SelectControl from './SelectControl'; +import TextControl from './TextControl'; +import CheckboxControl from './CheckboxControl'; + +import AnnotationTypes, { + DEFAULT_ANNOTATION_TYPE, + requiresQuery, + supportedSliceTypes } + from '../../../modules/AnnotationTypes'; + +import { ALL_COLOR_SCHEMES } from '../../../modules/colors'; +import PopoverSection from '../../../components/PopoverSection'; +import ControlHeader from '../ControlHeader'; +import { nonEmpty } from '../../validators'; +import vizTypes from '../../stores/visTypes'; + +const AUTOMATIC_COLOR = ''; + +const propTypes = { + name: PropTypes.string, + annotationType: PropTypes.string, + color: PropTypes.string, + opacity: PropTypes.string, + style: PropTypes.string, + width: PropTypes.number, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + overrides: PropTypes.object, + show: PropTypes.bool, + titleColumn: PropTypes.string, + descriptionColumns: PropTypes.arrayOf(PropTypes.string), + timeColumn: PropTypes.string, + intervalEndColumn: PropTypes.string, + + error: PropTypes.string, + colorScheme: PropTypes.string, + + addAnnotationLayer: PropTypes.func, + removeAnnotationLayer: PropTypes.func, + close: PropTypes.func, +}; + +const defaultProps = { + name: '', + annotationType: DEFAULT_ANNOTATION_TYPE, + color: AUTOMATIC_COLOR, + opacity: '', + style: 'solid', + width: 1, + overrides: {}, + colorScheme: 'd3Category10', + show: true, + titleColumn: '', + descriptionColumns: [], + timeColumn: '', + intervalEndColumn: '', + + addAnnotationLayer: () => {}, + removeAnnotationLayer: () => {}, + close: () => {}, +}; + +export default class AnnotationLayer extends React.PureComponent { + constructor(props) { + super(props); + const { name, annotationType, + color, opacity, style, width, value, + overrides, show, titleColumn, descriptionColumns, + timeColumn, intervalEndColumn } = props; + this.state = { + // base + name, + oldName: !this.props.name ? null : name, + annotationType, + value, + overrides, + show, + // slice + titleColumn, + descriptionColumns, + timeColumn, + intervalEndColumn, + // display + color: color || AUTOMATIC_COLOR, + opacity, + style, + width, + // refData + isNew: !this.props.name, + isLoadingOptions: true, + valueOptions: [], + }; + this.submitAnnotation = this.submitAnnotation.bind(this); + this.deleteAnnotation = this.deleteAnnotation.bind(this); + this.applyAnnotation = this.applyAnnotation.bind(this); + this.fetchOptions = this.fetchOptions.bind(this); + this.handleAnnotationType = this.handleAnnotationType.bind(this); + this.handleValue = this.handleValue.bind(this); + this.isValidForm = this.isValidForm.bind(this); + } + + componentDidMount() { + const { annotationType, isLoadingOptions } = this.state; + this.fetchOptions(annotationType, isLoadingOptions); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.annotationType !== this.state.annotationType) { + this.fetchOptions(this.state.annotationType, true); + } + } + + isValidFormula(value, annotationType) { + if (annotationType === AnnotationTypes.FORMULA) { + try { + mathjs.parse(value).compile().eval({ x: 0 }); + } catch (err) { + return true; + } + } + return false; + } + + isValidForm() { + const { name, annotationType, value, timeColumn, intervalEndColumn } = this.state; + const errors = [nonEmpty(name), nonEmpty(annotationType), nonEmpty(value)]; + if (annotationType === AnnotationTypes.EVENT) { + errors.push(nonEmpty(timeColumn)); + } + if (annotationType === AnnotationTypes.INTERVAL) { + errors.push(nonEmpty(timeColumn)); + errors.push(nonEmpty(intervalEndColumn)); + } + errors.push(this.isValidFormula(value, annotationType)); + return !errors.filter(x => x).length; + } + + handleAnnotationType(annotationType) { + this.setState({ + annotationType, + isLoadingOptions: true, + validationErrors: {}, + value: null, + }); + } + + handleValue(value) { + this.setState({ + value, + descriptionColumns: null, + intervalEndColumn: null, + timeColumn: null, + titleColumn: null, + overrides: {}, + }); + } + + fetchOptions(annotationType, isLoadingOptions) { + if (isLoadingOptions === true) { + if (annotationType === AnnotationTypes.NATIVE) { + $.ajax({ + type: 'GET', + url: '/annotationlayermodelview/api/read?', + }).then((data) => { + const layers = data ? data.result.map(layer => ({ + value: layer.id, + label: layer.name, + })) : []; + this.setState({ + isLoadingOptions: false, + valueOptions: layers, + }); + }); + } else if (requiresQuery(annotationType)) { + $.ajax({ + type: 'GET', + url: '/superset/user_slices', + }).then(data => + this.setState({ + isLoadingOptions: false, + valueOptions: data.filter( + x => supportedSliceTypes(annotationType) + .find(v => v === x.viz_type)) + .map(x => ({ value: x.id, label: x.title, slice: x }) + ), + }), + ); + } + } + } + + deleteAnnotation() { + this.props.close(); + if (!this.state.isNew) { + this.props.removeAnnotationLayer(this.state); + } + } + + applyAnnotation() { + if (this.state.name.length) { + const annotation = { ...this.state }; + annotation.color = annotation.color === AUTOMATIC_COLOR ? null : annotation.color; + delete annotation.isNew; + delete annotation.valueOptions; + delete annotation.isLoadingOptions; + this.props.addAnnotationLayer(annotation); + this.setState({ isNew: false, oldName: this.state.name }); + } + } + + submitAnnotation() { + this.applyAnnotation(); + this.props.close(); + } + + renderValueConfiguration() { + const { annotationType, value, valueOptions, isLoadingOptions } = this.state; + let label = ''; + let description = ''; + if (annotationType === AnnotationTypes.NATIVE) { + label = 'Annotation Layer'; + description = 'Select the Annotation Layer you would like to use.'; + } else if (requiresQuery(annotationType)) { + label = 'Slice'; + description = `Use a pre defined Superset Slice as a source for annotations and overlays. + 'your Slice must be one of these visualization types: + '[${supportedSliceTypes(annotationType).map(x => vizTypes[x].label).join(', ')}]'`; + } else if (annotationType === AnnotationTypes.FORMULA) { + label = 'Formula'; + description = `Expects a formula with depending time parameter 'x' + in milliseconds since epoch. mathjs is used to evaluate the formulas. + Example: '2x+5'`; + } + if (requiresQuery(annotationType) || annotationType === AnnotationTypes.NATIVE) { + return ( + + ); + } if (annotationType === AnnotationTypes.FORMULA) { + return ( + + ); + } + return ''; + } + + renderSliceConfiguration() { + const { annotationType, value, valueOptions, overrides, titleColumn, + timeColumn, intervalEndColumn, descriptionColumns } = this.state; + const slice = (valueOptions.find(x => x.value === value) || {}).slice; + if (requiresQuery(annotationType) && slice) { + const columns = (slice.form_data.groupby || []).concat( + (slice.form_data.all_columns || [])).map(x => ({ value: x, label: x })); + const timeColumnOptions = slice.form_data.include_time ? + [{ value: '__timestamp', label: '__timestamp' }].concat(columns) : columns; + return ( +
+ { + }} + title="Annotation Slice Configuration" + info={ + `This section allows you to configure how to use the slice + to generate annotations.` + } + > + { + ( + annotationType === AnnotationTypes.EVENT || + annotationType === AnnotationTypes.INTERVAL + ) && + this.setState({ timeColumn: v })} + /> + } + { + annotationType === AnnotationTypes.INTERVAL && + this.setState({ intervalEndColumn: v })} + /> + } + this.setState({ titleColumn: v })} + /> + { + annotationType !== AnnotationTypes.TIME_SERIES && + this.setState({ descriptionColumns: v })} + /> + } +
+ x === 'since')} + onChange={(v) => { + delete overrides.since; + if (v) { + this.setState({ overrides: { ...overrides, since: null } }); + } else { + this.setState({ overrides: { ...overrides } }); + } + }} + /> + x === 'until')} + onChange={(v) => { + delete overrides.until; + if (v) { + this.setState({ overrides: { ...overrides, until: null } }); + } else { + this.setState({ overrides: { ...overrides } }); + } + }} + /> + this.setState({ overrides: { ...overrides, time_shift: v } })} + /> +
+
+
+ ); + } + return (''); + } + + renderDisplayConfiguration() { + const { color, opacity, style, width } = this.state; + const colorScheme = [...ALL_COLOR_SCHEMES[this.props.colorScheme]]; + if (color && color !== AUTOMATIC_COLOR && + !colorScheme.find(x => x.toLowerCase() === color.toLowerCase())) { + colorScheme.push(color); + } + return ( + {}} + title="Display configuration" + info="Configure your how you overlay is displayed here." + > + this.setState({ style: v })} + /> + this.setState({ opacity: v })} + /> +
+ +
+ this.setState({ color: v.hex })} + /> + +
+
+ this.setState({ width: v })} + /> +
+ ); + } + + render() { + const { isNew, name, annotationType, show } = this.state; + const isValid = this.isValidForm(); + return ( +
+ { + this.props.error && + + ERROR: {this.props.error} + + } +
+
+ {}} + title="Layer Configuration" + info="Configure the basics of your Annotation Layer." + > + this.setState({ name: v })} + validationErrors={!name ? ['Mandatory'] : []} + /> + this.setState({ show: !v })} + /> + + { this.renderValueConfiguration() } + +
+ { this.renderSliceConfiguration() } + { this.renderDisplayConfiguration() } +
+
+ +
+ + + +
+
+
+ ); + } +} +AnnotationLayer.propTypes = propTypes; +AnnotationLayer.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx new file mode 100644 index 0000000000000..0a1a64e1459e3 --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx @@ -0,0 +1,173 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { OverlayTrigger, Popover, ListGroup, ListGroupItem } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import { getChartKey } from '../../exploreUtils' +import { runAnnotationQuery } from '../../../chart/chartAction'; +import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger'; + + +import AnnotationLayer from './AnnotationLayer'; +import { t } from '../../../locales'; + + +const propTypes = { + colorScheme: PropTypes.string.isRequired, + annotationError: PropTypes.object, + annotationQuery: PropTypes.object, + + validationErrors: PropTypes.array, + name: PropTypes.string.isRequired, + actions: PropTypes.object, + value: PropTypes.arrayOf(PropTypes.object), + onChange: PropTypes.func, + refreshAnnotationData: PropTypes.func, +}; + +const defaultProps = { + value: [], + annotationError: {}, + annotationQuery: {}, + onChange: () => {}, +}; + +class AnnotationLayerControl extends React.PureComponent { + constructor(props) { + super(props); + this.addAnnotationLayer = this.addAnnotationLayer.bind(this); + this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this); + } + + componentWillReceiveProps(nextProps) { + const { name, annotationError, validationErrors, value } = nextProps; + if (Object.keys(annotationError).length && !validationErrors.length) { + this.props.actions.setControlValue(name, value, Object.keys(annotationError)); + } + if (!Object.keys(annotationError).length && validationErrors.length) { + this.props.actions.setControlValue(name, value, []); + } + } + + addAnnotationLayer(annotationLayer) { + const annotation = annotationLayer; + let annotations = this.props.value.slice(); + const i = annotations.findIndex(x => x.name === (annotation.oldName || annotation.name)); + delete annotation.oldName; + if (i > -1) { + annotations[i] = annotation; + } else { + annotations = annotations.concat(annotation); + } + this.props.refreshAnnotationData(annotation); + this.props.onChange(annotations); + } + + removeAnnotationLayer(annotation) { + const annotations = this.props.value.slice() + .filter(x => x.name !== annotation.oldName); + this.props.onChange(annotations); + } + + renderPopover(parent, annotation, error) { + const id = !annotation ? '_new' : annotation.name; + return ( + + this.refs[parent].hide()} + /> + + ); + } + + renderInfo(anno) { + const { annotationError, annotationQuery } = this.props; + if (annotationQuery[anno.name]) { + return ( + + ); + } + if (annotationError[anno.name]) { + return ( + + ); + } + if (!anno.show) { + return Hidden ; + } + return ''; + } + + render() { + const annotations = this.props.value.map((anno, i) => ( + + + {anno.name} + + {this.renderInfo(anno)} + + + + )); + return ( +
+ + {annotations} + + +   {t('Add Annotation Layer')} + + + +
+ ); + } +} + +AnnotationLayerControl.propTypes = propTypes; +AnnotationLayerControl.defaultProps = defaultProps; + +// Tried to hook this up through stores/control.jsx instead of using redux +// directly, could not figure out how to get access to the color_scheme +function mapStateToProps({charts, explore}) { + const chartKey = getChartKey(explore); + return { + colorScheme: (explore.controls || {}).color_scheme.value, + annotationError: charts[chartKey].annotationError, + annotationQuery: charts[chartKey].annotationQuery, + }; +} + +function mapDispatchToProps(dispatch) { + return { + refreshAnnotationData: annotationLayer => dispatch(runAnnotationQuery(annotationLayer)), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationLayerControl); diff --git a/superset/assets/javascripts/explore/components/controls/index.js b/superset/assets/javascripts/explore/components/controls/index.js index 94b8c66ef6061..35aaeeff2b774 100644 --- a/superset/assets/javascripts/explore/components/controls/index.js +++ b/superset/assets/javascripts/explore/components/controls/index.js @@ -1,3 +1,4 @@ +import AnnotationLayerControl from './AnnotationLayerControl'; import BoundsControl from './BoundsControl'; import CheckboxControl from './CheckboxControl'; import CollectionControl from './CollectionControl'; @@ -18,6 +19,7 @@ import ViewportControl from './ViewportControl'; import VizTypeControl from './VizTypeControl'; const controlMap = { + AnnotationLayerControl, BoundsControl, CheckboxControl, CollectionControl, diff --git a/superset/assets/javascripts/explore/exploreUtils.js b/superset/assets/javascripts/explore/exploreUtils.js index 2356da5eb6256..3f0b4beacb600 100644 --- a/superset/assets/javascripts/explore/exploreUtils.js +++ b/superset/assets/javascripts/explore/exploreUtils.js @@ -1,13 +1,29 @@ /* eslint camelcase: 0 */ import URI from 'urijs'; +export function getChartKey(explore) { + const slice = explore.slice; + return slice ? ('slice_' + slice.slice_id) : 'slice'; +} + +export function getSliceJsonUrl(slice_id, form_data) { + if (slice_id === null || slice_id === undefined) { + return null; + } + const uri = URI(window.location.search); + return uri.pathname(`/superset/slice_json/${slice_id}`) + .search({ + form_data: JSON.stringify(form_data, + (key, value) => value === null ? undefined : value), + }).toString(); +} + export function getExploreUrl(form_data, endpointType = 'base', force = false, curUrl = null, requestParams = {}) { if (!form_data.datasource) { return null; } - // The search params from the window.location are carried through, // but can be specified with curUrl (used for unit tests to spoof // the window.location). diff --git a/superset/assets/javascripts/explore/index.jsx b/superset/assets/javascripts/explore/index.jsx index 7e2ed35926431..587efc4cd750a 100644 --- a/superset/assets/javascripts/explore/index.jsx +++ b/superset/assets/javascripts/explore/index.jsx @@ -7,6 +7,7 @@ import thunk from 'redux-thunk'; import { now } from '../modules/dates'; import { initEnhancer } from '../reduxUtils'; +import { getChartKey } from './exploreUtils' import AlertsWrapper from '../components/AlertsWrapper'; import { getControlsState, getFormDataFromControls } from './stores/store'; import { initJQueryAjax } from '../modules/utils'; @@ -41,7 +42,7 @@ const sliceFormData = slice ? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data)) : null; -const chartKey = slice ? ('slice_' + slice.slice_id) : 'slice'; +const chartKey = getChartKey(bootstrappedState); const initState = { charts: { [chartKey]: { diff --git a/superset/assets/javascripts/explore/main.css b/superset/assets/javascripts/explore/main.css index a6afe5eba935f..434e6f8acaf50 100644 --- a/superset/assets/javascripts/explore/main.css +++ b/superset/assets/javascripts/explore/main.css @@ -121,3 +121,4 @@ padding: 0; background-color: transparent; } + diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index b18039f232aa7..eb4efba5ada77 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -135,7 +135,6 @@ export const controls = { choices: (state.datasource) ? state.datasource.order_by_choices : [], }), }, - color_picker: { label: t('Fixed Color'), description: t('Use this to define a static color for all circles'), @@ -144,23 +143,6 @@ export const controls = { renderTrigger: true, }, - annotation_layers: { - type: 'SelectAsyncControl', - multi: true, - label: t('Annotation Layers'), - default: [], - description: t('Annotation layers to overlay on the visualization'), - dataEndpoint: '/annotationlayermodelview/api/read?', - placeholder: t('Select a annotation layer'), - onAsyncErrorMessage: t('Error while fetching annotation layers'), - mutator: (data) => { - if (!data || !data.result) { - return []; - } - return data.result.map(layer => ({ value: layer.id, label: layer.name })); - }, - }, - metric: { type: 'SelectControl', label: t('Metric'), @@ -1561,6 +1543,14 @@ export const controls = { }), }, + annotation_layers: { + type: 'AnnotationLayerControl', + label: '', + default: [], + description: 'Annotation Layers', + renderTrigger: true, + }, + having_filters: { type: 'FilterControl', label: '', diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index a243cbfd2d9c7..ef9dc4112cc51 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -45,7 +45,7 @@ export const sections = { description: t('This section exposes ways to include snippets of SQL in your query'), }, annotations: { - label: t('Annotations'), + label: t('Annotations and Layers'), expanded: true, controlSetRows: [ ['annotation_layers'], diff --git a/superset/assets/javascripts/modules/AnnotationTypes.js b/superset/assets/javascripts/modules/AnnotationTypes.js new file mode 100644 index 0000000000000..d7d96424dcbd5 --- /dev/null +++ b/superset/assets/javascripts/modules/AnnotationTypes.js @@ -0,0 +1,31 @@ +import { VIZ_TYPES } from '../../visualizations/main'; + +export const ANNOTATION_TYPES = { + FORMULA: 'FORMULA', + EVENT: 'EVENT', + INTERVAL: 'INTERVAL', + TIME_SERIES: 'TIME_SERIES', + NATIVE: 'NATIVE', +// POINT_ANNOTATION: 'POINT_ANNOTATION', +}; + +export const DEFAULT_ANNOTATION_TYPE = ANNOTATION_TYPES.FORMULA; + +export function supportedSliceTypes(annotationType) { + if (annotationType === ANNOTATION_TYPES.EVENT || + annotationType === ANNOTATION_TYPES.INTERVAL + ) return [VIZ_TYPES.table]; + if (annotationType === ANNOTATION_TYPES.TIME_SERIES) return [VIZ_TYPES.line]; + return []; +} + +export function requiresQuery(annotationType) { + if (annotationType === ANNOTATION_TYPES.FORMULA || + annotationType === ANNOTATION_TYPES.NATIVE + ) { + return false; + } + return true; +} + +export default ANNOTATION_TYPES; diff --git a/superset/assets/javascripts/modules/superset.js b/superset/assets/javascripts/modules/superset.js new file mode 100644 index 0000000000000..713652366d3b6 --- /dev/null +++ b/superset/assets/javascripts/modules/superset.js @@ -0,0 +1,293 @@ +/* eslint camel-case: 0 */ +import $ from 'jquery'; +import Mustache from 'mustache'; +import vizMap from '../../visualizations/main'; +import { getExploreUrl, getSliceJsonUrl } from '../explore/exploreUtils'; +import { applyDefaultFormData } from '../explore/stores/store'; +import { t } from '../locales'; +import { requiresQuery } from './AnnotationTypes'; + +const utils = require('./utils'); + +/* eslint wrap-iife: 0 */ +const px = function (state) { + let slice; + const timeout = state.common.conf.SUPERSET_WEBSERVER_TIMEOUT; + function getParam(name) { + /* eslint no-useless-escape: 0 */ + const formattedName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); + const regex = new RegExp('[\\?&]' + formattedName + '=([^&#]*)'); + const results = regex.exec(location.search); + return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); + } + function initFavStars() { + const baseUrl = '/superset/favstar/'; + // Init star behavihor for favorite + function show() { + if ($(this).hasClass('selected')) { + $(this).html(''); + } else { + $(this).html(''); + } + } + $('.favstar') + .attr('title', t('Click to favorite/unfavorite')) + .css('cursor', 'pointer') + .each(show) + .each(function () { + let url = baseUrl + $(this).attr('class_name'); + const star = this; + url += '/' + $(this).attr('obj_id') + '/'; + $.getJSON(url + 'count/', function (data) { + if (data.count > 0) { + $(star).addClass('selected').each(show); + } + }); + }) + .click(function () { + $(this).toggleClass('selected'); + let url = baseUrl + $(this).attr('class_name'); + url += '/' + $(this).attr('obj_id') + '/'; + if ($(this).hasClass('selected')) { + url += 'select/'; + } else { + url += 'unselect/'; + } + $.get(url); + $(this).each(show); + }) + .tooltip(); + } + + function runAnnotationQuery(annotation, timeout = 60, formData = null) { + const name = annotation.name; + if (!requiresQuery(annotation.annotationType)) { + return Promise.resolve(); + } + const sliceFormData = Object.keys(annotation.overrides) + .reduce((d, k) => ({ + ...d, + [k]: annotation.overrides[k] || formData[k], + }), {}); + const url = getSliceJsonUrl(annotation.value, sliceFormData, 'json'); + return $.ajax({ + url, + dataType: 'json', + timeout: timeout * 1000, + }).then(queryResponse => ({ [name]: queryResponse.data })) + .catch(() => {}); + } + const Slice = function (data, datasource, controller) { + const token = $('#token_' + data.slice_id); + const controls = $('#controls_' + data.slice_id); + const containerId = 'con_' + data.slice_id; + const selector = '#' + containerId; + const container = $(selector); + const sliceId = data.slice_id; + const formData = applyDefaultFormData(data.form_data); + const annotationData = []; + const sliceCell = $(`#${data.slice_id}-cell`); + slice = { + data, + formData, + container, + containerId, + datasource, + selector, + annotationData, + getWidgetHeader() { + return this.container.parents('div.widget').find('.chart-header'); + }, + render_template(s) { + const context = { + width: this.width, + height: this.height, + }; + return Mustache.render(s, context); + }, + jsonEndpoint(data) { + return this.endpoint(data, 'json'); + }, + endpoint(data, endpointType = 'json') { + let endpoint = getExploreUrl(data, endpointType, this.force); + if (endpoint.charAt(0) !== '/') { + // Known issue for IE <= 11: + // https://connect.microsoft.com/IE/feedbackdetail/view/1002846/pathname-incorrect-for-out-of-document-elements + endpoint = '/' + endpoint; + } + return endpoint; + }, + d3format(col, number) { + // uses the utils memoized d3format function and formats based on + // column level defined preferences + let format = '.3s'; + if (this.datasource.column_formats[col]) { + format = this.datasource.column_formats[col]; + } + return utils.d3format(format, number); + }, + /* eslint no-shadow: 0 */ + always(data) { + if (data && data.query) { + slice.viewSqlQuery = data.query; + } + }, + done(payload) { + Object.assign(data, payload); + + token.find('img.loading').hide(); + container.fadeTo(0.5, 1); + sliceCell.removeClass('slice-cell-highlight'); + container.show(); + + $('.query-and-save button').removeAttr('disabled'); + this.always(data); + controller.done(this); + }, + getErrorMsg(xhr) { + let msg = ''; + if (!xhr.responseText) { + const status = xhr.status; + if (status === 0) { + // This may happen when the worker in gunicorn times out + msg += ( + t('The server could not be reached. You may want to ' + + 'verify your connection and try again.')); + } else { + msg += (t('An unknown error occurred. (Status: %s )', status)); + } + } + return msg; + }, + error(msg, xhr) { + let errorMsg = msg; + token.find('img.loading').hide(); + container.fadeTo(0.5, 1); + sliceCell.removeClass('slice-cell-highlight'); + let errHtml = ''; + let o; + try { + o = JSON.parse(msg); + if (o.error) { + errorMsg = o.error; + } + } catch (e) { + // pass + } + if (errorMsg) { + errHtml += `
${errorMsg}
`; + } + if (xhr) { + if (xhr.statusText === 'timeout') { + errHtml += ( + '
' + + 'Query timeout - visualization query are set to time out ' + + `at ${timeout} seconds.
`); + } else { + const extendedMsg = this.getErrorMsg(xhr); + if (extendedMsg) { + errHtml += `
${extendedMsg}
`; + } + } + } + container.html(errHtml); + container.show(); + $('span.query').removeClass('disabled'); + $('.query-and-save button').removeAttr('disabled'); + this.always(o); + controller.error(this); + }, + clearError() { + $(selector + ' div.alert').remove(); + }, + width() { + return container.width(); + }, + height() { + let others = 0; + const widget = container.parents('.widget'); + const sliceDescription = widget.find('.slice_description'); + if (sliceDescription.is(':visible')) { + others += widget.find('.slice_description').height() + 25; + } + others += widget.find('.chart-header').height(); + return widget.height() - others - 10; + }, + bindResizeToWindowResize() { + let resizeTimer; + const slice = this; + $(window).on('resize', function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + slice.resize(); + }, 500); + }); + }, + render(force) { + if (force === undefined) { + this.force = false; + } else { + this.force = force; + } + const formDataExtra = Object.assign({}, formData); + formDataExtra.extra_filters = controller.effectiveExtraFilters(sliceId); + controls.find('a.exploreChart').attr('href', getExploreUrl(formDataExtra)); + controls.find('a.exportCSV').attr('href', getExploreUrl(formDataExtra, 'csv')); + token.find('img.loading').show(); + container.fadeTo(0.5, 0.25); + sliceCell.addClass('slice-cell-highlight'); + container.css('height', this.height()); + const asyncAnnotations = (formData.annotation_layers || []) + .map(x => runAnnotationQuery(x, timeout, formData)); + $.ajax({ + url: this.jsonEndpoint(formDataExtra), + timeout: timeout * 1000, + success: (queryResponse) => { + try { + this.done(queryResponse); + // render when all the annotations are available + Promise.all(asyncAnnotations) + .then((annotations) => { + this.annotationData = annotations + .reduce((data, a) => ({ ...data, ...a }), {}); + return Promise.resolve(); + }) + .then(() => vizMap[formData.viz_type](this, queryResponse)); + } catch (e) { + this.error(t('An error occurred while rendering the visualization: %s', e)); + } + }, + error: (err) => { + this.error(err.responseText, err); + }, + }); + }, + resize() { + this.render(); + }, + addFilter(col, vals, merge = true, refresh = true) { + controller.addFilter(sliceId, col, vals, merge, refresh); + }, + setFilter(col, vals, refresh = true) { + controller.setFilter(sliceId, col, vals, refresh); + }, + getFilters() { + return controller.filters[sliceId]; + }, + clearFilter() { + controller.clearFilter(sliceId); + }, + removeFilter(col, vals) { + controller.removeFilter(sliceId, col, vals); + }, + }; + return slice; + }; + // Export public functions + return { + getParam, + initFavStars, + Slice, + }; +}; +module.exports = px; diff --git a/superset/assets/package.json b/superset/assets/package.json index 9d8b01a811355..da896aafbec79 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -64,6 +64,7 @@ "jquery": "3.1.1", "lodash.throttle": "^4.1.1", "luma.gl": "^4.0.5", + "mathjs": "^3.16.3", "moment": "2.18.1", "mustache": "^2.2.1", "nvd3": "1.8.6", diff --git a/superset/assets/spec/javascripts/explore/chartActions_spec.js b/superset/assets/spec/javascripts/explore/chartActions_spec.js index f88de8f955310..4caeccd3e2b90 100644 --- a/superset/assets/spec/javascripts/explore/chartActions_spec.js +++ b/superset/assets/spec/javascripts/explore/chartActions_spec.js @@ -23,14 +23,16 @@ describe('chart actions', () => { }); it('should handle query timeout', () => { - ajaxStub.yieldsTo('error', { statusText: 'timeout' }); + ajaxStub.rejects({ statusText: 'timeout' }); request = actions.runQuery({}); - request(dispatch, sinon.stub().returns({ + const promise = request(dispatch, sinon.stub().returns({ explore: { controls: [], }, })); - expect(dispatch.callCount).to.equal(3); - expect(dispatch.args[0][0].type).to.equal(actions.CHART_UPDATE_TIMEOUT); + promise.then(() => { + expect(dispatch.callCount).to.equal(3); + expect(dispatch.args[0][0].type).to.equal(actions.CHART_UPDATE_TIMEOUT); + }); }); }); diff --git a/superset/assets/stylesheets/dashboard.css b/superset/assets/stylesheets/dashboard.css index b6d86abbd081e..c1f08a7e38b74 100644 --- a/superset/assets/stylesheets/dashboard.css +++ b/superset/assets/stylesheets/dashboard.css @@ -146,3 +146,11 @@ div.widget:hover .chart-controls { .slice_container .alert { margin: 10px; } + +i.danger { + color: red; +} + +i.warning { + color: orange; +} diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index d86ad74b8218b..ae0be2cd64ee5 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -376,7 +376,7 @@ iframe { padding-bottom: 10px; } .popover { - max-width: 500px !important; + max-width: 500px; } .float-left { float: left; diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js index 9976614048ada..5e1c5b8deec30 100644 --- a/superset/assets/visualizations/main.js +++ b/superset/assets/visualizations/main.js @@ -1,23 +1,63 @@ /* eslint-disable global-require */ + +// You ***should*** use these to reference viz_types in code +export const VIZ_TYPES = { + area: 'area', + bar: 'bar', + big_numberbig_number_total: 'big_numberbig_number_total', + box_plot: 'box_plot', + bubble: 'bubble', + bullet: 'bullet', + cal_heatmap: 'cal_heatmap', + compare: 'compare', + directed_force: 'directed_force', + chord: 'chord', + dist_bar: 'dist_bar', + filter_box: 'filter_box', + heatmap: 'heatmap', + histogram: 'histogram', + horizon: 'horizon', + iframe: 'iframe', + line: 'line', + mapbox: 'mapbox', + markup: 'markup', + para: 'para', + pie: 'pie', + pivot_table: 'pivot_table', + sankey: 'sankey', + separator: 'separator', + sunburst: 'sunburst', + table: 'table', + time_table: 'time_table', + treemap: 'treemap', + country_map: 'country_map', + word_cloud: 'word_cloud', + world_map: 'world_map', + dual_line: 'dual_line', + event_flow: 'event_flow', + paired_ttest: 'paired_ttest', + partition: 'partition', +}; + const vizMap = { - area: require('./nvd3_vis.js'), - bar: require('./nvd3_vis.js'), - big_number: require('./big_number.js'), - big_number_total: require('./big_number.js'), - box_plot: require('./nvd3_vis.js'), - bubble: require('./nvd3_vis.js'), - bullet: require('./nvd3_vis.js'), - cal_heatmap: require('./cal_heatmap.js'), - compare: require('./nvd3_vis.js'), - directed_force: require('./directed_force.js'), - chord: require('./chord.jsx'), - dist_bar: require('./nvd3_vis.js'), - filter_box: require('./filter_box.jsx'), - heatmap: require('./heatmap.js'), - histogram: require('./histogram.js'), - horizon: require('./horizon.js'), - iframe: require('./iframe.js'), - line: require('./nvd3_vis.js'), + [VIZ_TYPES.area]: require('./nvd3_vis.js'), + [VIZ_TYPES.bar]: require('./nvd3_vis.js'), + [VIZ_TYPES.big_number]: require('./big_number.js'), + [VIZ_TYPES.big_number_total]: require('./big_number.js'), + [VIZ_TYPES.box_plot]: require('./nvd3_vis.js'), + [VIZ_TYPES.bubble]: require('./nvd3_vis.js'), + [VIZ_TYPES.bullet]: require('./nvd3_vis.js'), + [VIZ_TYPES.cal_heatmap]: require('./cal_heatmap.js'), + [VIZ_TYPES.compare]: require('./nvd3_vis.js'), + [VIZ_TYPES.directed_force]: require('./directed_force.js'), + [VIZ_TYPES.chord]: require('./chord.jsx'), + [VIZ_TYPES.dist_bar]: require('./nvd3_vis.js'), + [VIZ_TYPES.filter_box]: require('./filter_box.jsx'), + [VIZ_TYPES.heatmap]: require('./heatmap.js'), + [VIZ_TYPES.histogram]: require('./histogram.js'), + [VIZ_TYPES.horizon]: require('./horizon.js'), + [VIZ_TYPES.iframe]: require('./iframe.js'), + [VIZ_TYPES.line]: require('./nvd3_vis.js'), time_pivot: require('./nvd3_vis.js'), mapbox: require('./mapbox.jsx'), markup: require('./markup.js'), diff --git a/superset/assets/visualizations/nvd3_vis.css b/superset/assets/visualizations/nvd3_vis.css index 1a5897f14d36f..fed0d013dc531 100644 --- a/superset/assets/visualizations/nvd3_vis.css +++ b/superset/assets/visualizations/nvd3_vis.css @@ -35,3 +35,32 @@ text.nv-axislabel { text.nv-axislabel { font-size: 14px !important; } + +g.solid path, line.solid { + stroke-dasharray: unset; +} + +g.dashed path, line.dashed { + stroke-dasharray: 5, 5; +} + +g.longDashed path, line.longDashed { + stroke-dasharray: 10, 2; +} + +g.dotted path, line.dotted { + stroke-dasharray: 1, 1; +} + +g.opacityLow path, line.opacityLow { + stroke-opacity: .2 +} + +g.opacityMedium path, line.opacityMedium { + stroke-opacity: .5 +} + +g.opacityHigh path, line.opacityHigh { + stroke-opacity: .8 +} + diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js index f1b6c11cde4a9..f13f8a8e87cb1 100644 --- a/superset/assets/visualizations/nvd3_vis.js +++ b/superset/assets/visualizations/nvd3_vis.js @@ -3,14 +3,17 @@ import $ from 'jquery'; import throttle from 'lodash.throttle'; import d3 from 'd3'; import nv from 'nvd3'; +import mathjs from 'mathjs'; import d3tip from 'd3-tip'; import { getColorFromScheme } from '../javascripts/modules/colors'; +import AnnotationTypes from '../javascripts/modules/AnnotationTypes'; import { customizeToolTip, d3TimeFormatPreset, d3FormatPreset, tryNumify } from '../javascripts/modules/utils'; // CSS import '../node_modules/nvd3/build/nv.d3.min.css'; import './nvd3_vis.css'; +import { VIZ_TYPES } from './main'; const minBarWidth = 15; const animationTime = 1000; @@ -392,7 +395,7 @@ function nvd3Vis(slice, payload) { return `rgba(${c.r}, ${c.g}, ${c.b}, ${alpha})`; }); } else if (vizType !== 'bullet') { - chart.color(d => getColorFromScheme(d[colorKey], fd.color_scheme)); + chart.color(d => d.color || getColorFromScheme(d[colorKey], fd.color_scheme)); } if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { chart.useInteractiveGuideline(true); @@ -526,82 +529,247 @@ function nvd3Vis(slice, payload) { .attr('width', width) .call(chart); - // add annotation_layer - if (isTimeSeries && payload.annotations && payload.annotations.length) { - const tip = d3tip() + // on scroll, hide tooltips. throttle to only 4x/second. + $(window).scroll(throttle(hideTooltips, 250)); + + const annotationLayers = (slice.formData.annotation_layers || []) + .filter(x => x.show); + if (isTimeSeries && annotationLayers) { + // Formula annotations + const formulas = annotationLayers.filter(a => a.annotationType === AnnotationTypes.FORMULA) + .map(a => ({ ...a, formula: mathjs.parse(a.value) })); + + let xMax; + let xMin; + let xScale; + if (vizType === VIZ_TYPES.bar) { + xMin = d3.min(data[0].values, d => (d.x)); + xMax = d3.max(data[0].values, d => (d.x)); + xScale = d3.scale.quantile() + .domain([xMin, xMax]) + .range(chart.xAxis.range()); + } else { + xMin = chart.xAxis.scale().domain()[0].valueOf(); + xMax = chart.xAxis.scale().domain()[1].valueOf(); + xScale = chart.xScale(); + } + + if (Array.isArray(formulas) && formulas.length) { + const xValues = []; + if (vizType === VIZ_TYPES.bar) { + // For bar-charts we want one data point evaluated for every + // data point that will be displayed. + const distinct = data.reduce((xVals, d) => { + d.values.forEach(x => xVals.add(x.x)); + return xVals; + }, new Set()); + xValues.push(...distinct.values()); + xValues.sort(); + } else { + // For every other time visualization it should be ok, to have a + // data points in even intervals. + let period = Math.min(...data.map(d => + Math.min(...d.values.slice(1).map((v, i) => v.x - d.values[i].x)))); + const dataPoints = (xMax - xMin) / (period || 1); + // make sure that there are enough data points and not too many + period = dataPoints < 100 ? (xMax - xMin) / 100 : period; + period = dataPoints > 500 ? (xMax - xMin) / 500 : period; + xValues.push(xMin); + for (let x = xMin; x < xMax; x += period) { + xValues.push(x); + } + xValues.push(xMax); + } + const formulaData = formulas.map(fo => ({ + key: fo.name, + values: xValues.map((x => ({ y: fo.formula.eval({ x }), x }))), + color: fo.color, + strokeWidth: fo.width, + classed: `${fo.opacity} ${fo.style}`, + })); + data.push(...formulaData); + } + + const annotationHeight = chart.yAxis.scale().range()[0]; + const tipFactory = layer => d3tip() .attr('class', 'd3-tip') .direction('n') .offset([-5, 0]) .html((d) => { - if (!d || !d.layer) { + if (!d) { return ''; } - - const title = d.short_descr ? - d.short_descr + ' - ' + d.layer : - d.layer; - const body = d.long_descr; + const title = d[layer.titleColumn] && d[layer.titleColumn].length ? + d[layer.titleColumn] + ' - ' + layer.name : + layer.name; + const body = Array.isArray(layer.descriptionColumns) ? + layer.descriptionColumns.map(c => d[c]) : Object.values(d); return '
' + title + '

' + - '
' + body + '
'; + '
' + body.join(', ') + '
'; }); - const hh = chart.yAxis.scale().range()[0]; + // Native annotations layers + annotationLayers.filter(x => ( + x.annotationType === AnnotationTypes.NATIVE && payload && payload.annotations + )).forEach((e, index) => { + const annotations = payload.annotations + .filter(x => x.layer_id === e.value); + if (annotations.length) { + let annotationLayer; + let minStep; + if (vizType === VIZ_TYPES.bar) { + minStep = chart.xAxis.range()[1] - chart.xAxis.range()[0]; + annotationLayer = d3.select(slice.selector).select('.nv-barsWrap') + .insert('g', ':first-child') + .attr('class', `native-bar-annotation-layer-${index}`); + } else { + minStep = 1; + annotationLayer = d3.select(slice.selector).select('.nv-wrap').append('g') + .attr('class', `nv-native-annotation-layer-${index}`); + } + const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); + const tip = tipFactory( + { + name: e.name, + titleColumn: 'short_descr', + descriptionColumns: ['long_descr'], + }); + + annotationLayer.selectAll('rect') + .data(annotations) + .enter() + .append('rect') + .attr('x', d => (xScale(d.start_dttm))) + .attr('y', 0) + .attr('width', (d) => { + const w = xScale(d.end_dttm) - xScale(d.start_dttm); + return w === 0 ? minStep : w; + }) + .attr('height', annotationHeight) + .attr('class', `${e.opacity} ${e.style}`) + .style('stroke-width', e.width) + .style('stroke', aColor) + .style('fill', aColor) + .style('fill-opacity', 0.2) + .on('mouseover', tip.show) + .on('mouseout', tip.hide) + .call(tip); + } + }); - let annotationLayer; - let xScale; - let minStep; - if (vizType === 'bar') { - const xMax = d3.max(payload.data[0].values, d => (d.x)); - const xMin = d3.min(payload.data[0].values, d => (d.x)); - minStep = chart.xAxis.range()[1] - chart.xAxis.range()[0]; - annotationLayer = svg.select('.nv-barsWrap') - .insert('g', ':first-child'); - xScale = d3.scale.quantile() - .domain([xMin, xMax]) - .range(chart.xAxis.range()); - } else { - minStep = 1; - annotationLayer = svg.select('.nv-background') - .append('g'); - xScale = chart.xScale(); - } + if (slice.annotationData && Object.keys(slice.annotationData).length) { + // Event annotations + annotationLayers.filter(x => ( + x.annotationType === AnnotationTypes.EVENT && + slice.annotationData && slice.annotationData[x.name] + )).forEach((e, index) => { + // Add event annotation layer + const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') + .attr('class', `nv-event-annotation-layer-${index}`); + const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); + + const tip = tipFactory(e); + const records = (slice.annotationData[e.name].records || []).map((r) => { + const timeColumn = new Date(r[e.timeColumn]); + return { + ...r, + [e.timeColumn]: timeColumn, + }; + }).filter(r => !Number.isNaN(r[e.timeColumn].getMilliseconds())); + if (records.length) { + annotations.selectAll('line') + .data(records) + .enter() + .append('line') + .attr({ + x1: d => xScale(new Date(d[e.timeColumn])), + y1: 0, + x2: d => xScale(new Date(d[e.timeColumn])), + y2: annotationHeight, + }) + .attr('class', `${e.opacity} ${e.style}`) + .style('stroke', aColor) + .style('stroke-width', e.width) + .on('mouseover', tip.show) + .on('mouseout', tip.hide) + .call(tip); + } + }); - annotationLayer - .attr('class', 'annotation-container') - .append('defs') - .append('pattern') - .attr('id', 'diagonal') - .attr('patternUnits', 'userSpaceOnUse') - .attr('width', 8) - .attr('height', 10) - .attr('patternTransform', 'rotate(45 50 50)') - .append('line') - .attr('stroke-width', 7) - .attr('y2', 10); - - annotationLayer.selectAll('rect') - .data(payload.annotations) - .enter() - .append('rect') - .attr('class', 'annotation') - .attr('x', d => (xScale(d.start_dttm))) - .attr('y', 0) - .attr('width', (d) => { - const w = xScale(d.end_dttm) - xScale(d.start_dttm); - return w === 0 ? minStep : w; - }) - .attr('height', hh) - .attr('fill', 'url(#diagonal)') - .on('mouseover', tip.show) - .on('mouseout', tip.hide); - - annotationLayer.selectAll('rect').call(tip); - } - } - // on scroll, hide tooltips. throttle to only 4x/second. - $(window).scroll(throttle(hideTooltips, 250)); + // Interval annotations + annotationLayers.filter(x => ( + x.annotationType === AnnotationTypes.INTERVAL && + slice.annotationData && slice.annotationData[x.name] + )).forEach((e, index) => { + // Add interval annotation layer + const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') + .attr('class', `nv-interval-annotation-layer-${index}`); + + const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); + const tip = tipFactory(e); + + const records = (slice.annotationData[e.name].records || []).map((r) => { + const timeColumn = new Date(r[e.timeColumn]); + const intervalEndColumn = new Date(r[e.intervalEndColumn]); + return { + ...r, + [e.timeColumn]: timeColumn, + [e.intervalEndColumn]: intervalEndColumn, + }; + }).filter(r => !Number.isNaN(r[e.timeColumn].getMilliseconds()) && + !Number.isNaN(r[e.intervalEndColumn].getMilliseconds())); + if (records.length) { + annotations.selectAll('rect') + .data(records) + .enter() + .append('rect') + .attr({ + x: d => Math.min(xScale(new Date(d[e.timeColumn])), + xScale(new Date(d[e.intervalEndColumn]))), + y: 0, + width: d => Math.abs(xScale(new Date(d[e.intervalEndColumn])) - + xScale(new Date(d[e.timeColumn]))), + height: annotationHeight, + }) + .attr('class', `${e.opacity} ${e.style}`) + .style('stroke-width', e.width) + .style('stroke', aColor) + .style('fill', aColor) + .style('fill-opacity', 0.2) + .on('mouseover', tip.show) + .on('mouseout', tip.hide) + .call(tip); + } + }); + + // Time series annotations + const timeSeriesAnnotations = annotationLayers + .filter(a => a.annotationType === AnnotationTypes.TIME_SERIES).reduce((bushel, a) => + bushel.concat((slice.annotationData[a.name] || []).map((series) => { + if (!series) { + return {}; + } + const key = Array.isArray(series.key) ? + `${a.name}, ${series.key.join(', ')}` : a.name; + return { + ...series, + key, + color: a.color, + strokeWidth: a.width, + classed: `${a.opacity} ${a.style}`, + }; + })), []); + data.push(...timeSeriesAnnotations); + } + } + // rerender chart + svg.datum(data) + .attr('height', height) + .attr('width', width) + .call(chart); + } return chart; }; diff --git a/superset/models/annotations.py b/superset/models/annotations.py index 8aac6a2217b09..e082be0923d11 100644 --- a/superset/models/annotations.py +++ b/superset/models/annotations.py @@ -48,6 +48,7 @@ class Annotation(Model, AuditMixinNullable): @property def data(self): return { + 'layer_id': self.layer_id, 'start_dttm': self.start_dttm, 'end_dttm': self.end_dttm, 'short_descr': self.short_descr, diff --git a/superset/views/core.py b/superset/views/core.py index 00254b4ca27ba..ce25b3b0b072e 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -946,7 +946,7 @@ def get_form_data(self): def get_viz( self, slice_id=None, - args=None, + form_data=None, datasource_type=None, datasource_id=None): if slice_id: @@ -957,7 +957,6 @@ def get_viz( ) return slc.get_viz() else: - form_data = self.get_form_data() viz_type = form_data.get('viz_type', 'table') datasource = ConnectorRegistry.get_datasource( datasource_type, datasource_id, db.session) @@ -967,13 +966,14 @@ def get_viz( ) return viz_obj + @has_access @expose('/slice//') def slice(self, slice_id): viz_obj = self.get_viz(slice_id) endpoint = ( '/superset/explore/{}/{}?form_data={}' - .format( + .format( viz_obj.datasource.type, viz_obj.datasource.id, parse.quote(json.dumps(viz_obj.form_data)), @@ -997,15 +997,13 @@ def get_query_string_response(self, viz_obj): status=200, mimetype='application/json') - @log_this - @has_access_api - @expose('/explore_json///') - def explore_json(self, datasource_type, datasource_id): + def generate_json(self, datasource_type, datasource_id, form_data, + csv=False, query=False, force=False): try: viz_obj = self.get_viz( datasource_type=datasource_type, datasource_id=datasource_id, - args=request.args) + form_data=form_data) except Exception as e: logging.exception(e) return json_error_response( @@ -1015,20 +1013,19 @@ def explore_json(self, datasource_type, datasource_id): if not self.datasource_access(viz_obj.datasource): return json_error_response(DATASOURCE_ACCESS_ERR, status=404) - if request.args.get('csv') == 'true': + if csv: return CsvResponse( viz_obj.get_csv(), status=200, headers=generate_download_headers('csv'), mimetype='application/csv') - if request.args.get('query') == 'true': + if query: return self.get_query_string_response(viz_obj) - payload = {} try: payload = viz_obj.get_payload( - force=request.args.get('force') == 'true') + force=force) except Exception as e: logging.exception(e) return json_error_response(utils.error_msg_from_exception(e)) @@ -1039,6 +1036,46 @@ def explore_json(self, datasource_type, datasource_id): return json_success(viz_obj.json_dumps(payload), status=status) + @log_this + @has_access_api + @expose("/slice_json/") + def slice_json(self, slice_id): + try: + viz_obj = self.get_viz(slice_id) + datasource_type = viz_obj.datasource.type + datasource_id = viz_obj.datasource.id + form_data = viz_obj.form_data + # This allows you to override the saved slice form data with + # data from the current request (e.g. change the time window) + form_data.update(self.get_form_data()) + except Exception as e: + return json_error_response( + utils.error_msg_from_exception(e), + stacktrace=traceback.format_exc()) + return self.generate_json(datasource_type=datasource_type, + datasource_id=datasource_id, + form_data=form_data) + + @log_this + @has_access_api + @expose("/explore_json///") + def explore_json(self, datasource_type, datasource_id): + try: + csv = request.args.get("csv") == "true" + query = request.args.get("query") == "true" + force = request.args.get("force") == "true" + form_data = self.get_form_data() + except Exception as e: + return json_error_response( + utils.error_msg_from_exception(e), + stacktrace=traceback.format_exc()) + return self.generate_json(datasource_type=datasource_type, + datasource_id=datasource_id, + form_data=form_data, + csv=csv, + query=query, + force=force) + @log_this @has_access @expose('/import_dashboards', methods=['GET', 'POST']) @@ -1644,9 +1681,50 @@ def created_dashboards(self, user_id): @api @has_access_api - @expose('/created_slices//', methods=['GET']) - def created_slices(self, user_id): + @expose("/user_slices", methods=['GET']) + @expose("/user_slices//", methods=['GET']) + def user_slices(self, user_id=None): + """List of slices a user created, or faved""" + if not user_id: + user_id = g.user.id + Slice = models.Slice # noqa + FavStar = models.FavStar # noqa + qry = ( + db.session.query(Slice, + FavStar.dttm).join( + models.FavStar, + sqla.and_( + models.FavStar.user_id == int(user_id), + models.FavStar.class_name == 'slice', + models.Slice.id == models.FavStar.obj_id, + ), + isouter=True).filter( + sqla.or_( + Slice.created_by_fk == user_id, + Slice.changed_by_fk == user_id, + FavStar.user_id == user_id, + ) + ) + .order_by(Slice.slice_name.asc()) + ) + payload = [{ + 'id': o.Slice.id, + 'title': o.Slice.slice_name, + 'url': o.Slice.slice_url, + 'form_data': o.Slice.form_data, + 'dttm': o.dttm if o.dttm else o.Slice.changed_on, + 'viz_type': o.Slice.viz_type + } for o in qry.all()] + return json_success( + json.dumps(payload, default=utils.json_int_dttm_ser)) + + @api + @has_access_api + @expose("/created_slices", methods=['GET']) + def created_slices(self, user_id=None): """List of slices created by this user""" + if not user_id: + user_id = g.user.id Slice = models.Slice # noqa qry = ( db.session.query(Slice) @@ -1663,15 +1741,19 @@ def created_slices(self, user_id): 'title': o.slice_name, 'url': o.slice_url, 'dttm': o.changed_on, + 'viz_type': o.viz_type } for o in qry.all()] return json_success( json.dumps(payload, default=utils.json_int_dttm_ser)) @api @has_access_api - @expose('/fave_slices//', methods=['GET']) - def fave_slices(self, user_id): + @expose("/fave_slices", methods=['GET']) + @expose("/fave_slices//", methods=['GET']) + def fave_slices(self, user_id=None): """Favorite slices for a user""" + if not user_id: + user_id = g.user.id qry = ( db.session.query( models.Slice, @@ -1696,6 +1778,7 @@ def fave_slices(self, user_id): 'title': o.Slice.slice_name, 'url': o.Slice.slice_url, 'dttm': o.dttm, + 'viz_type': o.Slice.viz_type } if o.Slice.created_by: user = o.Slice.created_by diff --git a/superset/viz.py b/superset/viz.py index 6551577de15c4..eeb31608e0163 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -37,6 +37,8 @@ config = app.config stats_logger = config.get('STATS_LOGGER') +NATIVE_ANNOTATION_TYPE = 'NATIVE' + class BaseViz(object): @@ -62,6 +64,7 @@ def __init__(self, datasource, form_data): self.metrics = self.form_data.get('metrics') or [] self.groupby = self.form_data.get('groupby') or [] self.annotation_layers = [] + self.time_shift = 0 self.status = None self.error_message = None @@ -121,6 +124,7 @@ def get_df(self, query_obj=None): df[DTTM_ALIAS], utc=False, format=timestamp_format) if self.datasource.offset: df[DTTM_ALIAS] += timedelta(hours=self.datasource.offset) + df[DTTM_ALIAS] += self.time_shift df.replace([np.inf, -np.inf], np.nan) fillna = self.get_fillna_for_columns(df.columns) df = df.fillna(fillna) @@ -158,6 +162,7 @@ def query_obj(self): since = form_data.get('since', '') until = form_data.get('until', 'now') + time_shift = form_data.get("time_shift", "") # Backward compatibility hack if since: @@ -166,9 +171,10 @@ def query_obj(self): if (len(since_words) == 2 and since_words[1] in grains): since += ' ago' - from_dttm = utils.parse_human_datetime(since) + self.time_shift = utils.parse_human_timedelta(time_shift) - to_dttm = utils.parse_human_datetime(until) + from_dttm = utils.parse_human_datetime(since) - self.time_shift + to_dttm = utils.parse_human_datetime(until) - self.time_shift if from_dttm and to_dttm and from_dttm > to_dttm: raise Exception(_('From date cannot be larger than to date')) @@ -230,13 +236,16 @@ def cache_key(self): def get_annotations(self): """Fetches the annotations for the specified layers and date range""" annotations = [] - if self.annotation_layers: + annotations_layers = [a.get('value') for a in self.annotation_layers + if a.get('annotationType') == + NATIVE_ANNOTATION_TYPE] + if annotations_layers: from superset.models.annotations import Annotation from superset import db qry = ( db.session .query(Annotation) - .filter(Annotation.layer_id.in_(self.annotation_layers))) + .filter(Annotation.layer_id.in_(annotations_layers))) if self.from_dttm: qry = qry.filter(Annotation.start_dttm >= self.from_dttm) if self.to_dttm: From 6e58e4009459b8ae7827668df0ca58693f8032e2 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 Dec 2017 11:32:26 -0500 Subject: [PATCH 2/8] Viz types --- superset/assets/visualizations/main.js | 50 ++++++++++++++------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js index 5e1c5b8deec30..7fb881993619c 100644 --- a/superset/assets/visualizations/main.js +++ b/superset/assets/visualizations/main.js @@ -37,6 +37,10 @@ export const VIZ_TYPES = { event_flow: 'event_flow', paired_ttest: 'paired_ttest', partition: 'partition', + deck_scatter: 'deck_scatter', + deck_screengrid: 'deck_screengrid', + deck_grid: 'deck_grid', + deck_hex: 'deck_hex', }; const vizMap = { @@ -58,28 +62,28 @@ const vizMap = { [VIZ_TYPES.horizon]: require('./horizon.js'), [VIZ_TYPES.iframe]: require('./iframe.js'), [VIZ_TYPES.line]: require('./nvd3_vis.js'), - time_pivot: require('./nvd3_vis.js'), - mapbox: require('./mapbox.jsx'), - markup: require('./markup.js'), - para: require('./parallel_coordinates.js'), - pie: require('./nvd3_vis.js'), - pivot_table: require('./pivot_table.js'), - sankey: require('./sankey.js'), - separator: require('./markup.js'), - sunburst: require('./sunburst.js'), - table: require('./table.js'), - time_table: require('./time_table.jsx'), - treemap: require('./treemap.js'), - country_map: require('./country_map.js'), - word_cloud: require('./word_cloud.js'), - world_map: require('./world_map.js'), - dual_line: require('./nvd3_vis.js'), - event_flow: require('./EventFlow.jsx'), - paired_ttest: require('./paired_ttest.jsx'), - partition: require('./partition.js'), - deck_scatter: require('./deckgl/scatter.jsx'), - deck_screengrid: require('./deckgl/screengrid.jsx'), - deck_grid: require('./deckgl/grid.jsx'), - deck_hex: require('./deckgl/hex.jsx'), + [VIZ_TYPES.time_pivot]: require('./nvd3_vis.js'), + [VIZ_TYPES.mapbox]: require('./mapbox.jsx'), + [VIZ_TYPES.markup]: require('./markup.js'), + [VIZ_TYPES.para]: require('./parallel_coordinates.js'), + [VIZ_TYPES.pie]: require('./nvd3_vis.js'), + [VIZ_TYPES.pivot_table]: require('./pivot_table.js'), + [VIZ_TYPES.sankey]: require('./sankey.js'), + [VIZ_TYPES.separator]: require('./markup.js'), + [VIZ_TYPES.sunburst]: require('./sunburst.js'), + [VIZ_TYPES.table]: require('./table.js'), + [VIZ_TYPES.time_table]: require('./time_table.jsx'), + [VIZ_TYPES.treemap]: require('./treemap.js'), + [VIZ_TYPES.country_map]: require('./country_map.js'), + [VIZ_TYPES.word_cloud]: require('./word_cloud.js'), + [VIZ_TYPES.world_map]: require('./world_map.js'), + [VIZ_TYPES.dual_line]: require('./nvd3_vis.js'), + [VIZ_TYPES.event_flow]: require('./EventFlow.jsx'), + [VIZ_TYPES.paired_ttest]: require('./paired_ttest.jsx'), + [VIZ_TYPES.partition]: require('./partition.js'), + [VIZ_TYPES.deck_scatter]: require('./deckgl/scatter.jsx'), + [VIZ_TYPES.deck_screengrid]: require('./deckgl/screengrid.jsx'), + [VIZ_TYPES.deck_grid]: require('./deckgl/grid.jsx'), + [VIZ_TYPES.deck_hex]: require('./deckgl/hex.jsx'), }; export default vizMap; From fb7d5ea22e39c9487d458245b4c7106c0b76a31c Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 Dec 2017 22:45:14 -0500 Subject: [PATCH 3/8] Re organizing native annotations --- .../assets/javascripts/chart/chartAction.js | 10 +- .../components/controls/AnnotationLayer.jsx | 119 ++++++++++++------ .../controls/AnnotationLayerControl.jsx | 4 + .../javascripts/explore/exploreUtils.js | 5 +- .../javascripts/modules/AnnotationTypes.js | 91 ++++++++++++-- superset/assets/visualizations/nvd3_vis.js | 55 +------- superset/connectors/sqla/models.py | 36 ++++++ superset/views/core.py | 30 ++++- superset/viz.py | 27 ---- 9 files changed, 241 insertions(+), 136 deletions(-) diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js index fa8fc0c02c16f..c1f22ebad7750 100644 --- a/superset/assets/javascripts/chart/chartAction.js +++ b/superset/assets/javascripts/chart/chartAction.js @@ -1,5 +1,5 @@ -import { getExploreUrl, getSliceJsonUrl } from '../explore/exploreUtils'; -import { requiresQuery } from '../modules/AnnotationTypes'; +import { getExploreUrl, getAnnotationJsonUrl } from '../explore/exploreUtils'; +import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes'; const $ = window.$ = require('jquery'); @@ -61,15 +61,17 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke const sliceKey = key || Object.keys(getState().charts)[0]; const fd = formData || getState().charts[sliceKey].latestQueryFormData; - if (!requiresQuery(annotation.annotationType)) { + if (!requiresQuery(annotation.sourceType)) { return Promise.resolve(); } + const sliceFormData = Object.keys(annotation.overrides) .reduce((d, k) => ({ ...d, [k]: annotation.overrides[k] || fd[k], }), {}); - const url = getSliceJsonUrl(annotation.value, sliceFormData, 'json'); + const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE; + const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative); const queryRequest = $.ajax({ url, dataType: 'json', diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx index ee5af00c18ff6..b8b12f5d22ea2 100644 --- a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx @@ -12,9 +12,13 @@ import CheckboxControl from './CheckboxControl'; import AnnotationTypes, { DEFAULT_ANNOTATION_TYPE, + ANNOTATION_SOURCE_TYPES, + getAnnotationSourceTypeLabels, + getAnnotationTypeLabel, + getSupportedSourceTypes, + getSupportedAnnotationTypes, requiresQuery, - supportedSliceTypes } - from '../../../modules/AnnotationTypes'; +} from '../../../modules/AnnotationTypes'; import { ALL_COLOR_SCHEMES } from '../../../modules/colors'; import PopoverSection from '../../../components/PopoverSection'; @@ -27,6 +31,7 @@ const AUTOMATIC_COLOR = ''; const propTypes = { name: PropTypes.string, annotationType: PropTypes.string, + sourceType: PropTypes.string, color: PropTypes.string, opacity: PropTypes.string, style: PropTypes.string, @@ -38,6 +43,7 @@ const propTypes = { descriptionColumns: PropTypes.arrayOf(PropTypes.string), timeColumn: PropTypes.string, intervalEndColumn: PropTypes.string, + vizType: PropTypes.string, error: PropTypes.string, colorScheme: PropTypes.string, @@ -50,6 +56,7 @@ const propTypes = { const defaultProps = { name: '', annotationType: DEFAULT_ANNOTATION_TYPE, + sourceType: '', color: AUTOMATIC_COLOR, opacity: '', style: 'solid', @@ -70,7 +77,7 @@ const defaultProps = { export default class AnnotationLayer extends React.PureComponent { constructor(props) { super(props); - const { name, annotationType, + const { name, annotationType, sourceType, color, opacity, style, width, value, overrides, show, titleColumn, descriptionColumns, timeColumn, intervalEndColumn } = props; @@ -79,6 +86,7 @@ export default class AnnotationLayer extends React.PureComponent { name, oldName: !this.props.name ? null : name, annotationType, + sourceType, value, overrides, show, @@ -102,18 +110,20 @@ export default class AnnotationLayer extends React.PureComponent { this.applyAnnotation = this.applyAnnotation.bind(this); this.fetchOptions = this.fetchOptions.bind(this); this.handleAnnotationType = this.handleAnnotationType.bind(this); + this.handleAnnotationSourceType = + this.handleAnnotationSourceType.bind(this); this.handleValue = this.handleValue.bind(this); this.isValidForm = this.isValidForm.bind(this); } componentDidMount() { - const { annotationType, isLoadingOptions } = this.state; - this.fetchOptions(annotationType, isLoadingOptions); + const { annotationType, sourceType, isLoadingOptions } = this.state; + this.fetchOptions(annotationType, sourceType, isLoadingOptions); } componentDidUpdate(prevProps, prevState) { - if (prevState.annotationType !== this.state.annotationType) { - this.fetchOptions(this.state.annotationType, true); + if (prevState.sourceType !== this.state.sourceType) { + this.fetchOptions(this.state.annotationType, this.state.sourceType, true); } } @@ -129,22 +139,37 @@ export default class AnnotationLayer extends React.PureComponent { } isValidForm() { - const { name, annotationType, value, timeColumn, intervalEndColumn } = this.state; + const { + name, annotationType, sourceType, + value, timeColumn, intervalEndColumn + } = this.state; const errors = [nonEmpty(name), nonEmpty(annotationType), nonEmpty(value)]; - if (annotationType === AnnotationTypes.EVENT) { - errors.push(nonEmpty(timeColumn)); - } - if (annotationType === AnnotationTypes.INTERVAL) { - errors.push(nonEmpty(timeColumn)); - errors.push(nonEmpty(intervalEndColumn)); + if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) { + if (annotationType === AnnotationTypes.EVENT) { + errors.push(nonEmpty(timeColumn)); + } + if (annotationType === AnnotationTypes.INTERVAL) { + errors.push(nonEmpty(timeColumn)); + errors.push(nonEmpty(intervalEndColumn)); + } } errors.push(this.isValidFormula(value, annotationType)); return !errors.filter(x => x).length; } + handleAnnotationType(annotationType) { this.setState({ annotationType, + sourceType: null, + validationErrors: {}, + value: null, + }); + } + + handleAnnotationSourceType(sourceType) { + this.setState({ + sourceType, isLoadingOptions: true, validationErrors: {}, value: null, @@ -158,13 +183,13 @@ export default class AnnotationLayer extends React.PureComponent { intervalEndColumn: null, timeColumn: null, titleColumn: null, - overrides: {}, + overrides: { since: null, until: null }, }); } - fetchOptions(annotationType, isLoadingOptions) { + fetchOptions(annotationType, sourceType, isLoadingOptions) { if (isLoadingOptions === true) { - if (annotationType === AnnotationTypes.NATIVE) { + if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { $.ajax({ type: 'GET', url: '/annotationlayermodelview/api/read?', @@ -178,7 +203,7 @@ export default class AnnotationLayer extends React.PureComponent { valueOptions: layers, }); }); - } else if (requiresQuery(annotationType)) { + } else if (requiresQuery(sourceType)) { $.ajax({ type: 'GET', url: '/superset/user_slices', @@ -186,12 +211,17 @@ export default class AnnotationLayer extends React.PureComponent { this.setState({ isLoadingOptions: false, valueOptions: data.filter( - x => supportedSliceTypes(annotationType) + x => getSupportedSourceTypes(annotationType) .find(v => v === x.viz_type)) .map(x => ({ value: x.id, label: x.title, slice: x }) ), }), ); + } else { + this.setState({ + isLoadingOptions: false, + valueOptions: [], + }); } } } @@ -221,24 +251,28 @@ export default class AnnotationLayer extends React.PureComponent { } renderValueConfiguration() { - const { annotationType, value, valueOptions, isLoadingOptions } = this.state; + const { annotationType, sourceType, value, + valueOptions, isLoadingOptions } = this.state; let label = ''; let description = ''; - if (annotationType === AnnotationTypes.NATIVE) { - label = 'Annotation Layer'; - description = 'Select the Annotation Layer you would like to use.'; - } else if (requiresQuery(annotationType)) { - label = 'Slice'; - description = `Use a pre defined Superset Slice as a source for annotations and overlays. + if (requiresQuery(sourceType)) { + if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { + label = 'Annotation Layer'; + description = 'Select the Annotation Layer you would like to use.'; + } else { + label = 'Slice'; + description = `Use a pre defined Superset Slice as a source for annotations and overlays. 'your Slice must be one of these visualization types: - '[${supportedSliceTypes(annotationType).map(x => vizTypes[x].label).join(', ')}]'`; + '[${getSupportedSourceTypes(sourceType) + .map(x => vizTypes[x].label).join(', ')}]'`; + } } else if (annotationType === AnnotationTypes.FORMULA) { label = 'Formula'; description = `Expects a formula with depending time parameter 'x' in milliseconds since epoch. mathjs is used to evaluate the formulas. Example: '2x+5'`; } - if (requiresQuery(annotationType) || annotationType === AnnotationTypes.NATIVE) { + if (requiresQuery(sourceType)) { return ( x.value === value) || {}).slice; - if (requiresQuery(annotationType) && slice) { + if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && slice) { const columns = (slice.form_data.groupby || []).concat( (slice.form_data.all_columns || [])).map(x => ({ value: x, label: x })); const timeColumnOptions = slice.form_data.include_time ? @@ -474,7 +508,8 @@ export default class AnnotationLayer extends React.PureComponent { } render() { - const { isNew, name, annotationType, show } = this.state; + const { isNew, name, annotationType, + sourceType, show } = this.state; const isValid = this.isValidForm(); return (
@@ -511,17 +546,23 @@ export default class AnnotationLayer extends React.PureComponent { description="Choose the Annotation Layer Type" label="Annotation Layer Type" name="annotation-layer-type" - options={[ - { value: AnnotationTypes.FORMULA, label: 'Formula' }, - { value: AnnotationTypes.NATIVE, label: 'Superset Annotation' }, - { value: AnnotationTypes.EVENT, label: 'Event' }, - { value: AnnotationTypes.INTERVAL, label: 'Interval' }, - { value: AnnotationTypes.TIME_SERIES, label: 'Time Series' }, - //{ value: AnnotationTypes.POINT_ANNOTATION, label: 'Point Annotations' }, - ]} + options={getSupportedAnnotationTypes(this.props.vizType).map( + x => ({ value: x, label: getAnnotationTypeLabel(x) }))} value={annotationType} onChange={this.handleAnnotationType} /> + {!!getSupportedSourceTypes(annotationType).length && + ({ value: x, label: getAnnotationSourceTypeLabels(x)}))} + value={sourceType} + onChange={this.handleAnnotationSourceType} + /> + } { this.renderValueConfiguration() }
diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx index 0a1a64e1459e3..62a64cdaa583e 100644 --- a/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx @@ -15,6 +15,7 @@ const propTypes = { colorScheme: PropTypes.string.isRequired, annotationError: PropTypes.object, annotationQuery: PropTypes.object, + vizType: PropTypes.string, validationErrors: PropTypes.array, name: PropTypes.string.isRequired, @@ -25,6 +26,7 @@ const propTypes = { }; const defaultProps = { + vizType: '', value: [], annotationError: {}, annotationQuery: {}, @@ -80,6 +82,7 @@ class AnnotationLayerControl extends React.PureComponent { {...annotation} error={error} colorScheme={this.props.colorScheme} + vizType={this.props.vizType} addAnnotationLayer={this.addAnnotationLayer} removeAnnotationLayer={this.removeAnnotationLayer} close={() => this.refs[parent].hide()} @@ -161,6 +164,7 @@ function mapStateToProps({charts, explore}) { colorScheme: (explore.controls || {}).color_scheme.value, annotationError: charts[chartKey].annotationError, annotationQuery: charts[chartKey].annotationQuery, + vizType: explore.controls['viz_type'].value, }; } diff --git a/superset/assets/javascripts/explore/exploreUtils.js b/superset/assets/javascripts/explore/exploreUtils.js index 3f0b4beacb600..8a01745d9a39f 100644 --- a/superset/assets/javascripts/explore/exploreUtils.js +++ b/superset/assets/javascripts/explore/exploreUtils.js @@ -6,12 +6,13 @@ export function getChartKey(explore) { return slice ? ('slice_' + slice.slice_id) : 'slice'; } -export function getSliceJsonUrl(slice_id, form_data) { +export function getAnnotationJsonUrl(slice_id, form_data, isNative) { if (slice_id === null || slice_id === undefined) { return null; } const uri = URI(window.location.search); - return uri.pathname(`/superset/slice_json/${slice_id}`) + const endpoint = isNative ? 'annotation_json' : 'slice_json'; + return uri.pathname(`/superset/${endpoint}/${slice_id}`) .search({ form_data: JSON.stringify(form_data, (key, value) => value === null ? undefined : value), diff --git a/superset/assets/javascripts/modules/AnnotationTypes.js b/superset/assets/javascripts/modules/AnnotationTypes.js index d7d96424dcbd5..9a46c18fc7973 100644 --- a/superset/assets/javascripts/modules/AnnotationTypes.js +++ b/superset/assets/javascripts/modules/AnnotationTypes.js @@ -1,31 +1,96 @@ import { VIZ_TYPES } from '../../visualizations/main'; +import vizTypes from '../explore/stores/visTypes'; export const ANNOTATION_TYPES = { FORMULA: 'FORMULA', EVENT: 'EVENT', INTERVAL: 'INTERVAL', TIME_SERIES: 'TIME_SERIES', - NATIVE: 'NATIVE', // POINT_ANNOTATION: 'POINT_ANNOTATION', }; +export const ANNOTATION_TYPE_LABELS = { + FORMULA: 'Formula ', + EVENT: 'Event', + INTERVAL: 'Interval', + TIME_SERIES: 'Time Series', +// POINT_ANNOTATION: 'POINT_ANNOTATION', +}; + +export function getAnnotationTypeLabel(annotationType){ + return ANNOTATION_TYPE_LABELS[annotationType]; +} + export const DEFAULT_ANNOTATION_TYPE = ANNOTATION_TYPES.FORMULA; -export function supportedSliceTypes(annotationType) { - if (annotationType === ANNOTATION_TYPES.EVENT || - annotationType === ANNOTATION_TYPES.INTERVAL - ) return [VIZ_TYPES.table]; - if (annotationType === ANNOTATION_TYPES.TIME_SERIES) return [VIZ_TYPES.line]; - return []; +export const ANNOTATION_SOURCE_TYPES = { + NATIVE: 'NATIVE', + ...VIZ_TYPES +}; + +export function getAnnotationSourceTypeLabels(sourceType){ + return ANNOTATION_SOURCE_TYPES.NATIVE === sourceType ? 'Superset annotation' : + vizTypes[sourceType].label; +} + +export function requiresQuery(annotationSourceType) { + return !!annotationSourceType } -export function requiresQuery(annotationType) { - if (annotationType === ANNOTATION_TYPES.FORMULA || - annotationType === ANNOTATION_TYPES.NATIVE - ) { - return false; +// Map annotation type to annotation source type +const SUPPORTED_SOURCE_TYPE_MAP = { + [ANNOTATION_TYPES.EVENT]: [ + ANNOTATION_SOURCE_TYPES.NATIVE, + ANNOTATION_SOURCE_TYPES.table + ], + [ANNOTATION_TYPES.INTERVAL]: [ + ANNOTATION_SOURCE_TYPES.NATIVE, + ANNOTATION_SOURCE_TYPES.table + ], + [ANNOTATION_TYPES.TIME_SERIES]: [ + ANNOTATION_SOURCE_TYPES.line + ], +}; + +export function getSupportedSourceTypes(annotationType) { + return SUPPORTED_SOURCE_TYPE_MAP[annotationType] || []; +} + +// Map from viz type to supported annotation +const SUPPORTED_ANNOTATIONS = { + [VIZ_TYPES.line]: [ + ANNOTATION_TYPES.TIME_SERIES, + ANNOTATION_TYPES.INTERVAL, + ANNOTATION_TYPES.EVENT, + ANNOTATION_TYPES.FORMULA, + ], + [VIZ_TYPES.bar]: [ + ANNOTATION_TYPES.INTERVAL, + ANNOTATION_TYPES.EVENT, + ], + [VIZ_TYPES.area]: [ + ANNOTATION_TYPES.INTERVAL, + ANNOTATION_TYPES.EVENT, + ], +}; + +export function getSupportedAnnotationTypes(vizType){ + return SUPPORTED_ANNOTATIONS[vizType] || []; +} + +const NATIVE_COLUMN_NAMES = { + timeColumn: 'start_dttm', + intervalEndColumn: 'end_dttm', + titleColumn: 'short_descr', + descriptionColumns: ['long_descr'], +}; + +export function applyNativeColumns(annotation){ + if (annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { + annotation = {...annotation, ...NATIVE_COLUMN_NAMES}; } - return true; + return annotation } export default ANNOTATION_TYPES; + diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js index f13f8a8e87cb1..6cb4b207bedb3 100644 --- a/superset/assets/visualizations/nvd3_vis.js +++ b/superset/assets/visualizations/nvd3_vis.js @@ -7,7 +7,9 @@ import mathjs from 'mathjs'; import d3tip from 'd3-tip'; import { getColorFromScheme } from '../javascripts/modules/colors'; -import AnnotationTypes from '../javascripts/modules/AnnotationTypes'; +import AnnotationTypes, { + applyNativeColumns, +} from '../javascripts/modules/AnnotationTypes'; import { customizeToolTip, d3TimeFormatPreset, d3FormatPreset, tryNumify } from '../javascripts/modules/utils'; // CSS @@ -608,61 +610,13 @@ function nvd3Vis(slice, payload) { '
' + body.join(', ') + '
'; }); - // Native annotations layers - annotationLayers.filter(x => ( - x.annotationType === AnnotationTypes.NATIVE && payload && payload.annotations - )).forEach((e, index) => { - const annotations = payload.annotations - .filter(x => x.layer_id === e.value); - if (annotations.length) { - let annotationLayer; - let minStep; - if (vizType === VIZ_TYPES.bar) { - minStep = chart.xAxis.range()[1] - chart.xAxis.range()[0]; - annotationLayer = d3.select(slice.selector).select('.nv-barsWrap') - .insert('g', ':first-child') - .attr('class', `native-bar-annotation-layer-${index}`); - } else { - minStep = 1; - annotationLayer = d3.select(slice.selector).select('.nv-wrap').append('g') - .attr('class', `nv-native-annotation-layer-${index}`); - } - const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); - const tip = tipFactory( - { - name: e.name, - titleColumn: 'short_descr', - descriptionColumns: ['long_descr'], - }); - - annotationLayer.selectAll('rect') - .data(annotations) - .enter() - .append('rect') - .attr('x', d => (xScale(d.start_dttm))) - .attr('y', 0) - .attr('width', (d) => { - const w = xScale(d.end_dttm) - xScale(d.start_dttm); - return w === 0 ? minStep : w; - }) - .attr('height', annotationHeight) - .attr('class', `${e.opacity} ${e.style}`) - .style('stroke-width', e.width) - .style('stroke', aColor) - .style('fill', aColor) - .style('fill-opacity', 0.2) - .on('mouseover', tip.show) - .on('mouseout', tip.hide) - .call(tip); - } - }); - if (slice.annotationData && Object.keys(slice.annotationData).length) { // Event annotations annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.EVENT && slice.annotationData && slice.annotationData[x.name] )).forEach((e, index) => { + e = applyNativeColumns(e); // Add event annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-event-annotation-layer-${index}`); @@ -702,6 +656,7 @@ function nvd3Vis(slice, payload) { x.annotationType === AnnotationTypes.INTERVAL && slice.annotationData && slice.annotationData[x.name] )).forEach((e, index) => { + e = applyNativeColumns(e); // Add interval annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-interval-annotation-layer-${index}`); diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index e0952288ffdcb..0d7f9040d27ae 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -21,12 +21,48 @@ from superset import db, import_util, sm, utils from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.jinja_context import get_template_processor +from superset.models.annotations import Annotation from superset.models.core import Database from superset.models.helpers import QueryResult from superset.models.helpers import set_perm from superset.utils import DTTM_ALIAS, QueryStatus +class AnnotationDatasource(BaseDatasource): + + """ Dummy object so we can query annotations using 'Viz' objects just like + regular datasources. + """ + + cache_timeout = 0; + def query(self, query_obj): + df = None + error_message = None + qry = db.session.query(Annotation) + qry = qry.filter(Annotation.layer_id == query_obj['filter'][0]['val']) + qry = qry.filter(Annotation.start_dttm >= query_obj['from_dttm']) + qry = qry.filter(Annotation.end_dttm <= query_obj['to_dttm']) + status = QueryStatus.SUCCESS + try: + df = pd.read_sql_query(qry.statement, db.engine) + except Exception as e: + status = QueryStatus.FAILED + logging.exception(e) + error_message = ( + utils.error_msg_from_exception(e)) + return QueryResult( + status=status, + df=df, + duration=0, + query='', + error_message=error_message) + + def get_query_str(self, query_obj): + raise NotImplementedError() + + def values_for_column(self, column_name, limit=10000): + raise NotImplementedError() + class TableColumn(Model, BaseColumn): """ORM object for table columns, each table can have multiple columns""" diff --git a/superset/views/core.py b/superset/views/core.py index ce25b3b0b072e..6e09991448a89 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -37,10 +37,11 @@ viz, ) from superset.connectors.connector_registry import ConnectorRegistry -from superset.connectors.sqla.models import SqlaTable +from superset.connectors.sqla.models import AnnotationDatasource, SqlaTable from superset.forms import CsvToDatabaseForm from superset.legacy import cast_form_data import superset.models.core as models +from superset.models.annotations import Annotation from superset.models.sql_lab import Query from superset.sql_parse import SupersetQuery from superset.utils import has_access, merge_extra_filters, QueryStatus @@ -1056,6 +1057,33 @@ def slice_json(self, slice_id): datasource_id=datasource_id, form_data=form_data) + @log_this + @has_access_api + @expose("/annotation_json/") + def annotation_json(self, layer_id): + form_data = self.get_form_data() + form_data['layer_id'] = layer_id + form_data['filters'] = [{'col': 'layer_id', + 'op':'==', + 'val': layer_id}] + + datasource = AnnotationDatasource() + viz_obj = viz.viz_types['table']( + datasource, + form_data=form_data, + ) + try: + payload = viz_obj.get_payload(force=False) + except Exception as e: + logging.exception(e) + return json_error_response(utils.error_msg_from_exception(e)) + + status = 200 + if payload.get('status') == QueryStatus.FAILED: + status = 400 + + return json_success(viz_obj.json_dumps(payload), status=status) + @log_this @has_access_api @expose("/explore_json///") diff --git a/superset/viz.py b/superset/viz.py index eeb31608e0163..e88f157e019a1 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -37,8 +37,6 @@ config = app.config stats_logger = config.get('STATS_LOGGER') -NATIVE_ANNOTATION_TYPE = 'NATIVE' - class BaseViz(object): @@ -63,7 +61,6 @@ def __init__(self, datasource, form_data): 'token', 'token_' + uuid.uuid4().hex[:8]) self.metrics = self.form_data.get('metrics') or [] self.groupby = self.form_data.get('groupby') or [] - self.annotation_layers = [] self.time_shift = 0 self.status = None @@ -180,7 +177,6 @@ def query_obj(self): self.from_dttm = from_dttm self.to_dttm = to_dttm - self.annotation_layers = form_data.get('annotation_layers') or [] # extras are used to query elements specific to a datasource type # for instance the extra where clause that applies only to Tables @@ -233,26 +229,6 @@ def cache_key(self): s = str([(k, form_data[k]) for k in sorted(form_data.keys())]) return hashlib.md5(s.encode('utf-8')).hexdigest() - def get_annotations(self): - """Fetches the annotations for the specified layers and date range""" - annotations = [] - annotations_layers = [a.get('value') for a in self.annotation_layers - if a.get('annotationType') == - NATIVE_ANNOTATION_TYPE] - if annotations_layers: - from superset.models.annotations import Annotation - from superset import db - qry = ( - db.session - .query(Annotation) - .filter(Annotation.layer_id.in_(annotations_layers))) - if self.from_dttm: - qry = qry.filter(Annotation.start_dttm >= self.from_dttm) - if self.to_dttm: - qry = qry.filter(Annotation.end_dttm <= self.to_dttm) - annotations = [o.data for o in qry.all()] - return annotations - def get_payload(self, force=False): """Handles caching around the json payload retrieval""" cache_key = self.cache_key @@ -281,13 +257,11 @@ def get_payload(self, force=False): is_cached = False cache_timeout = self.cache_timeout stacktrace = None - annotations = [] rowcount = None try: df = self.get_df() if not self.error_message: data = self.get_data(df) - annotations = self.get_annotations() rowcount = len(df.index) except Exception as e: logging.exception(e) @@ -305,7 +279,6 @@ def get_payload(self, force=False): 'query': self.query, 'status': self.status, 'stacktrace': stacktrace, - 'annotations': annotations, 'rowcount': rowcount, } payload['cached_dttm'] = datetime.utcnow().isoformat().split('.')[0] From abde7e88c82eda21b1c9a570a6abf0f8b6ca94af Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 14 Dec 2017 11:39:03 -0500 Subject: [PATCH 4/8] liniting --- .../assets/javascripts/chart/chartAction.js | 2 +- .../dashboard/components/GridCell.jsx | 2 +- .../dashboard/components/SliceHeader.jsx | 14 +-- .../components/controls/AnnotationLayer.jsx | 114 +++++++++--------- .../controls/AnnotationLayerControl.jsx | 6 +- superset/assets/javascripts/explore/index.jsx | 2 +- .../javascripts/modules/AnnotationTypes.js | 22 ++-- superset/assets/package.json | 1 + superset/assets/visualizations/nvd3_vis.js | 8 +- superset/connectors/sqla/models.py | 7 +- superset/views/core.py | 51 ++++---- superset/viz.py | 4 +- tests/viz_tests.py | 6 +- 13 files changed, 119 insertions(+), 120 deletions(-) diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js index c1f22ebad7750..9c5e3ce00c7e8 100644 --- a/superset/assets/javascripts/chart/chartAction.js +++ b/superset/assets/javascripts/chart/chartAction.js @@ -48,7 +48,7 @@ export function annotationQuerySuccess(annotation, queryResponse, key) { export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED'; export function annotationQueryStarted(annotation, queryRequest, key) { - return {type: ANNOTATION_QUERY_STARTED, annotation, queryRequest, key }; + return { type: ANNOTATION_QUERY_STARTED, annotation, queryRequest, key }; } export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED'; diff --git a/superset/assets/javascripts/dashboard/components/GridCell.jsx b/superset/assets/javascripts/dashboard/components/GridCell.jsx index 8277dde59a8f2..4f7213d3b08cc 100644 --- a/superset/assets/javascripts/dashboard/components/GridCell.jsx +++ b/superset/assets/javascripts/dashboard/components/GridCell.jsx @@ -85,7 +85,7 @@ class GridCell extends React.PureComponent { const { exploreChartUrl, exportCSVUrl, isExpanded, isLoading, isCached, cachedDttm, removeSlice, updateSliceName, toggleExpandSlice, forceRefresh, - chartKey, slice, datasource, formData, timeout, annotationQuery + chartKey, slice, datasource, formData, timeout, annotationQuery, } = this.props; return (
{!!Object.values(this.props.annotationQuery || {}).length && - + } {!!Object.values(this.props.annotationError || {}).length && diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx index b8b12f5d22ea2..2deac50b04baa 100644 --- a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx @@ -141,7 +141,7 @@ export default class AnnotationLayer extends React.PureComponent { isValidForm() { const { name, annotationType, sourceType, - value, timeColumn, intervalEndColumn + value, timeColumn, intervalEndColumn, } = this.state; const errors = [nonEmpty(name), nonEmpty(annotationType), nonEmpty(value)]; if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) { @@ -213,7 +213,7 @@ export default class AnnotationLayer extends React.PureComponent { valueOptions: data.filter( x => getSupportedSourceTypes(annotationType) .find(v => v === x.viz_type)) - .map(x => ({ value: x.id, label: x.title, slice: x }) + .map(x => ({ value: x.id, label: x.title, slice: x }), ), }), ); @@ -446,64 +446,64 @@ export default class AnnotationLayer extends React.PureComponent { colorScheme.push(color); } return ( - {}} - title="Display configuration" - info="Configure your how you overlay is displayed here." - > - {}} + title="Display configuration" + info="Configure your how you overlay is displayed here." + > + this.setState({ style: v })} - /> - this.setState({ style: v })} + /> + this.setState({ opacity: v })} - /> -
- -
- this.setState({ color: v.hex })} - /> - -
+ ]} + value={opacity} + onChange={v => this.setState({ opacity: v })} + /> +
+ +
+ this.setState({ color: v.hex })} + /> +
- this.setState({ width: v })} - /> - +
+ this.setState({ width: v })} + /> + ); } @@ -553,14 +553,14 @@ export default class AnnotationLayer extends React.PureComponent { /> {!!getSupportedSourceTypes(annotationType).length && ({ value: x, label: getAnnotationSourceTypeLabels(x)}))} - value={sourceType} - onChange={this.handleAnnotationSourceType} + hovered + description="Choose the source of your annotations" + label="Annotation Source" + name="annotation-source-type" + options={getSupportedSourceTypes(annotationType).map( + x => ({ value: x, label: getAnnotationSourceTypeLabels(x) }))} + value={sourceType} + onChange={this.handleAnnotationSourceType} /> } { this.renderValueConfiguration() } diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx index 62a64cdaa583e..3e4cd24e31c4d 100644 --- a/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayerControl.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { OverlayTrigger, Popover, ListGroup, ListGroupItem } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { getChartKey } from '../../exploreUtils' +import { getChartKey } from '../../exploreUtils'; import { runAnnotationQuery } from '../../../chart/chartAction'; import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger'; @@ -158,13 +158,13 @@ AnnotationLayerControl.defaultProps = defaultProps; // Tried to hook this up through stores/control.jsx instead of using redux // directly, could not figure out how to get access to the color_scheme -function mapStateToProps({charts, explore}) { +function mapStateToProps({ charts, explore }) { const chartKey = getChartKey(explore); return { colorScheme: (explore.controls || {}).color_scheme.value, annotationError: charts[chartKey].annotationError, annotationQuery: charts[chartKey].annotationQuery, - vizType: explore.controls['viz_type'].value, + vizType: explore.controls.viz_type.value, }; } diff --git a/superset/assets/javascripts/explore/index.jsx b/superset/assets/javascripts/explore/index.jsx index 587efc4cd750a..d66ad52e79fab 100644 --- a/superset/assets/javascripts/explore/index.jsx +++ b/superset/assets/javascripts/explore/index.jsx @@ -7,7 +7,7 @@ import thunk from 'redux-thunk'; import { now } from '../modules/dates'; import { initEnhancer } from '../reduxUtils'; -import { getChartKey } from './exploreUtils' +import { getChartKey } from './exploreUtils'; import AlertsWrapper from '../components/AlertsWrapper'; import { getControlsState, getFormDataFromControls } from './stores/store'; import { initJQueryAjax } from '../modules/utils'; diff --git a/superset/assets/javascripts/modules/AnnotationTypes.js b/superset/assets/javascripts/modules/AnnotationTypes.js index 9a46c18fc7973..f1f768d57b93c 100644 --- a/superset/assets/javascripts/modules/AnnotationTypes.js +++ b/superset/assets/javascripts/modules/AnnotationTypes.js @@ -17,7 +17,7 @@ export const ANNOTATION_TYPE_LABELS = { // POINT_ANNOTATION: 'POINT_ANNOTATION', }; -export function getAnnotationTypeLabel(annotationType){ +export function getAnnotationTypeLabel(annotationType) { return ANNOTATION_TYPE_LABELS[annotationType]; } @@ -25,30 +25,30 @@ export const DEFAULT_ANNOTATION_TYPE = ANNOTATION_TYPES.FORMULA; export const ANNOTATION_SOURCE_TYPES = { NATIVE: 'NATIVE', - ...VIZ_TYPES + ...VIZ_TYPES, }; -export function getAnnotationSourceTypeLabels(sourceType){ +export function getAnnotationSourceTypeLabels(sourceType) { return ANNOTATION_SOURCE_TYPES.NATIVE === sourceType ? 'Superset annotation' : vizTypes[sourceType].label; } export function requiresQuery(annotationSourceType) { - return !!annotationSourceType + return !!annotationSourceType; } // Map annotation type to annotation source type const SUPPORTED_SOURCE_TYPE_MAP = { [ANNOTATION_TYPES.EVENT]: [ ANNOTATION_SOURCE_TYPES.NATIVE, - ANNOTATION_SOURCE_TYPES.table + ANNOTATION_SOURCE_TYPES.table, ], [ANNOTATION_TYPES.INTERVAL]: [ ANNOTATION_SOURCE_TYPES.NATIVE, - ANNOTATION_SOURCE_TYPES.table + ANNOTATION_SOURCE_TYPES.table, ], [ANNOTATION_TYPES.TIME_SERIES]: [ - ANNOTATION_SOURCE_TYPES.line + ANNOTATION_SOURCE_TYPES.line, ], }; @@ -74,7 +74,7 @@ const SUPPORTED_ANNOTATIONS = { ], }; -export function getSupportedAnnotationTypes(vizType){ +export function getSupportedAnnotationTypes(vizType) { return SUPPORTED_ANNOTATIONS[vizType] || []; } @@ -85,11 +85,11 @@ const NATIVE_COLUMN_NAMES = { descriptionColumns: ['long_descr'], }; -export function applyNativeColumns(annotation){ +export function applyNativeColumns(annotation) { if (annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { - annotation = {...annotation, ...NATIVE_COLUMN_NAMES}; + return { ...annotation, ...NATIVE_COLUMN_NAMES }; } - return annotation + return annotation; } export default ANNOTATION_TYPES; diff --git a/superset/assets/package.json b/superset/assets/package.json index da896aafbec79..905e770294b06 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -15,6 +15,7 @@ "prod": "NODE_ENV=production node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js -p --colors --progress", "build": "NODE_ENV=production webpack --colors --progress", "lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx .", + "lint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx .", "sync-backend": "babel-node --presets env javascripts/syncBackend.js" }, "repository": { diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js index 6cb4b207bedb3..0e3d9e87a6d51 100644 --- a/superset/assets/visualizations/nvd3_vis.js +++ b/superset/assets/visualizations/nvd3_vis.js @@ -615,8 +615,8 @@ function nvd3Vis(slice, payload) { annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.EVENT && slice.annotationData && slice.annotationData[x.name] - )).forEach((e, index) => { - e = applyNativeColumns(e); + )).forEach((config, index) => { + const e = applyNativeColumns(config); // Add event annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-event-annotation-layer-${index}`); @@ -655,8 +655,8 @@ function nvd3Vis(slice, payload) { annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.INTERVAL && slice.annotationData && slice.annotationData[x.name] - )).forEach((e, index) => { - e = applyNativeColumns(e); + )).forEach((config, index) => { + const e = applyNativeColumns(config); // Add interval annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-interval-annotation-layer-${index}`); diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 0d7f9040d27ae..18803c565b76f 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -29,12 +29,12 @@ class AnnotationDatasource(BaseDatasource): - """ Dummy object so we can query annotations using 'Viz' objects just like regular datasources. """ - cache_timeout = 0; + cache_timeout = 0 + def query(self, query_obj): df = None error_message = None @@ -49,7 +49,7 @@ def query(self, query_obj): status = QueryStatus.FAILED logging.exception(e) error_message = ( - utils.error_msg_from_exception(e)) + utils.error_msg_from_exception(e)) return QueryResult( status=status, df=df, @@ -63,6 +63,7 @@ def get_query_str(self, query_obj): def values_for_column(self, column_name, limit=10000): raise NotImplementedError() + class TableColumn(Model, BaseColumn): """ORM object for table columns, each table can have multiple columns""" diff --git a/superset/views/core.py b/superset/views/core.py index 6e09991448a89..59106f0b86090 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -41,7 +41,6 @@ from superset.forms import CsvToDatabaseForm from superset.legacy import cast_form_data import superset.models.core as models -from superset.models.annotations import Annotation from superset.models.sql_lab import Query from superset.sql_parse import SupersetQuery from superset.utils import has_access, merge_extra_filters, QueryStatus @@ -967,19 +966,15 @@ def get_viz( ) return viz_obj - @has_access @expose('/slice//') def slice(self, slice_id): viz_obj = self.get_viz(slice_id) - endpoint = ( - '/superset/explore/{}/{}?form_data={}' - .format( + endpoint = '/superset/explore/{}/{}?form_data={}'.format( viz_obj.datasource.type, viz_obj.datasource.id, parse.quote(json.dumps(viz_obj.form_data)), ) - ) if request.args.get('standalone') == 'true': endpoint += '&standalone=true' return redirect(endpoint) @@ -1039,7 +1034,7 @@ def generate_json(self, datasource_type, datasource_id, form_data, @log_this @has_access_api - @expose("/slice_json/") + @expose('/slice_json/') def slice_json(self, slice_id): try: viz_obj = self.get_viz(slice_id) @@ -1059,39 +1054,36 @@ def slice_json(self, slice_id): @log_this @has_access_api - @expose("/annotation_json/") + @expose('/annotation_json/') def annotation_json(self, layer_id): form_data = self.get_form_data() form_data['layer_id'] = layer_id form_data['filters'] = [{'col': 'layer_id', - 'op':'==', + 'op': '==', 'val': layer_id}] - datasource = AnnotationDatasource() viz_obj = viz.viz_types['table']( datasource, form_data=form_data, ) try: - payload = viz_obj.get_payload(force=False) + payload = viz_obj.get_payload(force=False) except Exception as e: - logging.exception(e) - return json_error_response(utils.error_msg_from_exception(e)) - + logging.exception(e) + return json_error_response(utils.error_msg_from_exception(e)) status = 200 if payload.get('status') == QueryStatus.FAILED: - status = 400 - + status = 400 return json_success(viz_obj.json_dumps(payload), status=status) @log_this @has_access_api - @expose("/explore_json///") + @expose('/explore_json///') def explore_json(self, datasource_type, datasource_id): try: - csv = request.args.get("csv") == "true" - query = request.args.get("query") == "true" - force = request.args.get("force") == "true" + csv = request.args.get('csv') == 'true' + query = request.args.get('query') == 'true' + force = request.args.get('force') == 'true' form_data = self.get_form_data() except Exception as e: return json_error_response( @@ -1709,8 +1701,8 @@ def created_dashboards(self, user_id): @api @has_access_api - @expose("/user_slices", methods=['GET']) - @expose("/user_slices//", methods=['GET']) + @expose('/user_slices', methods=['GET']) + @expose('/user_slices//', methods=['GET']) def user_slices(self, user_id=None): """List of slices a user created, or faved""" if not user_id: @@ -1731,7 +1723,7 @@ def user_slices(self, user_id=None): Slice.created_by_fk == user_id, Slice.changed_by_fk == user_id, FavStar.user_id == user_id, - ) + ), ) .order_by(Slice.slice_name.asc()) ) @@ -1741,14 +1733,15 @@ def user_slices(self, user_id=None): 'url': o.Slice.slice_url, 'form_data': o.Slice.form_data, 'dttm': o.dttm if o.dttm else o.Slice.changed_on, - 'viz_type': o.Slice.viz_type + 'viz_type': o.Slice.viz_type, } for o in qry.all()] return json_success( json.dumps(payload, default=utils.json_int_dttm_ser)) @api @has_access_api - @expose("/created_slices", methods=['GET']) + @expose('/created_slices', methods=['GET']) + @expose('/created_slices//', methods=['GET']) def created_slices(self, user_id=None): """List of slices created by this user""" if not user_id: @@ -1769,15 +1762,15 @@ def created_slices(self, user_id=None): 'title': o.slice_name, 'url': o.slice_url, 'dttm': o.changed_on, - 'viz_type': o.viz_type + 'viz_type': o.viz_type, } for o in qry.all()] return json_success( json.dumps(payload, default=utils.json_int_dttm_ser)) @api @has_access_api - @expose("/fave_slices", methods=['GET']) - @expose("/fave_slices//", methods=['GET']) + @expose('/fave_slices', methods=['GET']) + @expose('/fave_slices//', methods=['GET']) def fave_slices(self, user_id=None): """Favorite slices for a user""" if not user_id: @@ -1806,7 +1799,7 @@ def fave_slices(self, user_id=None): 'title': o.Slice.slice_name, 'url': o.Slice.slice_url, 'dttm': o.dttm, - 'viz_type': o.Slice.viz_type + 'viz_type': o.Slice.viz_type, } if o.Slice.created_by: user = o.Slice.created_by diff --git a/superset/viz.py b/superset/viz.py index e88f157e019a1..9b913e4dbe150 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -61,7 +61,7 @@ def __init__(self, datasource, form_data): 'token', 'token_' + uuid.uuid4().hex[:8]) self.metrics = self.form_data.get('metrics') or [] self.groupby = self.form_data.get('groupby') or [] - self.time_shift = 0 + self.time_shift = timedelta() self.status = None self.error_message = None @@ -159,7 +159,7 @@ def query_obj(self): since = form_data.get('since', '') until = form_data.get('until', 'now') - time_shift = form_data.get("time_shift", "") + time_shift = form_data.get('time_shift', '') # Backward compatibility hack if since: diff --git a/tests/viz_tests.py b/tests/viz_tests.py index 06096e9292dd1..67f4bf89b374e 100644 --- a/tests/viz_tests.py +++ b/tests/viz_tests.py @@ -89,9 +89,13 @@ def test_get_df_handles_dttm_col(self): mock_call = df.__setitem__.mock_calls[2] self.assertEqual(mock_call[1][0], DTTM_ALIAS) self.assertFalse(mock_call[1][1].empty) - self.assertEqual(mock_call[1][1][0].hour, 6) + self.assertEqual(mock_call[1][1][0].hour, 7) mock_call = df.__setitem__.mock_calls[3] self.assertEqual(mock_call[1][0], DTTM_ALIAS) + self.assertEqual(mock_call[1][1][0].hour, 6) + self.assertEqual(mock_call[1][1].dtype, 'datetime64[ns]') + mock_call = df.__setitem__.mock_calls[4] + self.assertEqual(mock_call[1][0], DTTM_ALIAS) self.assertEqual(mock_call[1][1][0].hour, 7) self.assertEqual(mock_call[1][1].dtype, 'datetime64[ns]') From ff69e89a51da10f114da56ead38de3202aa5d3a2 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 Dec 2017 16:30:34 -0500 Subject: [PATCH 5/8] Bug fix --- superset/assets/visualizations/main.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js index 7fb881993619c..fdcb7b1eab2e0 100644 --- a/superset/assets/visualizations/main.js +++ b/superset/assets/visualizations/main.js @@ -4,7 +4,8 @@ export const VIZ_TYPES = { area: 'area', bar: 'bar', - big_numberbig_number_total: 'big_numberbig_number_total', + big_number: 'big_number', + big_number_total: 'big_number_total', box_plot: 'box_plot', bubble: 'bubble', bullet: 'bullet', From c53bfe0765ac62dea4b72dcfda4d76fedb9f3456 Mon Sep 17 00:00:00 2001 From: Fabian Date: Sat, 16 Dec 2017 12:35:14 -0500 Subject: [PATCH 6/8] Handle no data --- superset/assets/javascripts/chart/chartAction.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js index 9c5e3ce00c7e8..8ca53ea79e17f 100644 --- a/superset/assets/javascripts/chart/chartAction.js +++ b/superset/assets/javascripts/chart/chartAction.js @@ -83,6 +83,8 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke .catch((err) => { if (err.statusText === 'timeout') { dispatch(annotationQueryFailed(annotation, { error: 'Query Timeout' }, sliceKey)); + } else if ((err.responseJSON.error || '').toLowerCase().startsWith('no data')) { + dispatch(annotationQuerySuccess(annotation, err, sliceKey)); } else if (err.statusText !== 'abort') { dispatch(annotationQueryFailed(annotation, err.responseJSON, sliceKey)); } From 7131f22bf8c2f44a0ffb054183f15821e36d9fdd Mon Sep 17 00:00:00 2001 From: Fabian Date: Sat, 16 Dec 2017 13:41:53 -0500 Subject: [PATCH 7/8] Cleanup --- .../assets/javascripts/chart/chartAction.js | 1 - .../javascripts/modules/AnnotationTypes.js | 2 - .../assets/javascripts/modules/superset.js | 293 ------------------ 3 files changed, 296 deletions(-) delete mode 100644 superset/assets/javascripts/modules/superset.js diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js index 8ca53ea79e17f..a6341ddb6a7ba 100644 --- a/superset/assets/javascripts/chart/chartAction.js +++ b/superset/assets/javascripts/chart/chartAction.js @@ -89,7 +89,6 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke dispatch(annotationQueryFailed(annotation, err.responseJSON, sliceKey)); } }); - // .then(() => dispatch(renderTriggered(true, sliceKey))); }; } diff --git a/superset/assets/javascripts/modules/AnnotationTypes.js b/superset/assets/javascripts/modules/AnnotationTypes.js index f1f768d57b93c..28684bbcb6884 100644 --- a/superset/assets/javascripts/modules/AnnotationTypes.js +++ b/superset/assets/javascripts/modules/AnnotationTypes.js @@ -6,7 +6,6 @@ export const ANNOTATION_TYPES = { EVENT: 'EVENT', INTERVAL: 'INTERVAL', TIME_SERIES: 'TIME_SERIES', -// POINT_ANNOTATION: 'POINT_ANNOTATION', }; export const ANNOTATION_TYPE_LABELS = { @@ -14,7 +13,6 @@ export const ANNOTATION_TYPE_LABELS = { EVENT: 'Event', INTERVAL: 'Interval', TIME_SERIES: 'Time Series', -// POINT_ANNOTATION: 'POINT_ANNOTATION', }; export function getAnnotationTypeLabel(annotationType) { diff --git a/superset/assets/javascripts/modules/superset.js b/superset/assets/javascripts/modules/superset.js deleted file mode 100644 index 713652366d3b6..0000000000000 --- a/superset/assets/javascripts/modules/superset.js +++ /dev/null @@ -1,293 +0,0 @@ -/* eslint camel-case: 0 */ -import $ from 'jquery'; -import Mustache from 'mustache'; -import vizMap from '../../visualizations/main'; -import { getExploreUrl, getSliceJsonUrl } from '../explore/exploreUtils'; -import { applyDefaultFormData } from '../explore/stores/store'; -import { t } from '../locales'; -import { requiresQuery } from './AnnotationTypes'; - -const utils = require('./utils'); - -/* eslint wrap-iife: 0 */ -const px = function (state) { - let slice; - const timeout = state.common.conf.SUPERSET_WEBSERVER_TIMEOUT; - function getParam(name) { - /* eslint no-useless-escape: 0 */ - const formattedName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); - const regex = new RegExp('[\\?&]' + formattedName + '=([^&#]*)'); - const results = regex.exec(location.search); - return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); - } - function initFavStars() { - const baseUrl = '/superset/favstar/'; - // Init star behavihor for favorite - function show() { - if ($(this).hasClass('selected')) { - $(this).html(''); - } else { - $(this).html(''); - } - } - $('.favstar') - .attr('title', t('Click to favorite/unfavorite')) - .css('cursor', 'pointer') - .each(show) - .each(function () { - let url = baseUrl + $(this).attr('class_name'); - const star = this; - url += '/' + $(this).attr('obj_id') + '/'; - $.getJSON(url + 'count/', function (data) { - if (data.count > 0) { - $(star).addClass('selected').each(show); - } - }); - }) - .click(function () { - $(this).toggleClass('selected'); - let url = baseUrl + $(this).attr('class_name'); - url += '/' + $(this).attr('obj_id') + '/'; - if ($(this).hasClass('selected')) { - url += 'select/'; - } else { - url += 'unselect/'; - } - $.get(url); - $(this).each(show); - }) - .tooltip(); - } - - function runAnnotationQuery(annotation, timeout = 60, formData = null) { - const name = annotation.name; - if (!requiresQuery(annotation.annotationType)) { - return Promise.resolve(); - } - const sliceFormData = Object.keys(annotation.overrides) - .reduce((d, k) => ({ - ...d, - [k]: annotation.overrides[k] || formData[k], - }), {}); - const url = getSliceJsonUrl(annotation.value, sliceFormData, 'json'); - return $.ajax({ - url, - dataType: 'json', - timeout: timeout * 1000, - }).then(queryResponse => ({ [name]: queryResponse.data })) - .catch(() => {}); - } - const Slice = function (data, datasource, controller) { - const token = $('#token_' + data.slice_id); - const controls = $('#controls_' + data.slice_id); - const containerId = 'con_' + data.slice_id; - const selector = '#' + containerId; - const container = $(selector); - const sliceId = data.slice_id; - const formData = applyDefaultFormData(data.form_data); - const annotationData = []; - const sliceCell = $(`#${data.slice_id}-cell`); - slice = { - data, - formData, - container, - containerId, - datasource, - selector, - annotationData, - getWidgetHeader() { - return this.container.parents('div.widget').find('.chart-header'); - }, - render_template(s) { - const context = { - width: this.width, - height: this.height, - }; - return Mustache.render(s, context); - }, - jsonEndpoint(data) { - return this.endpoint(data, 'json'); - }, - endpoint(data, endpointType = 'json') { - let endpoint = getExploreUrl(data, endpointType, this.force); - if (endpoint.charAt(0) !== '/') { - // Known issue for IE <= 11: - // https://connect.microsoft.com/IE/feedbackdetail/view/1002846/pathname-incorrect-for-out-of-document-elements - endpoint = '/' + endpoint; - } - return endpoint; - }, - d3format(col, number) { - // uses the utils memoized d3format function and formats based on - // column level defined preferences - let format = '.3s'; - if (this.datasource.column_formats[col]) { - format = this.datasource.column_formats[col]; - } - return utils.d3format(format, number); - }, - /* eslint no-shadow: 0 */ - always(data) { - if (data && data.query) { - slice.viewSqlQuery = data.query; - } - }, - done(payload) { - Object.assign(data, payload); - - token.find('img.loading').hide(); - container.fadeTo(0.5, 1); - sliceCell.removeClass('slice-cell-highlight'); - container.show(); - - $('.query-and-save button').removeAttr('disabled'); - this.always(data); - controller.done(this); - }, - getErrorMsg(xhr) { - let msg = ''; - if (!xhr.responseText) { - const status = xhr.status; - if (status === 0) { - // This may happen when the worker in gunicorn times out - msg += ( - t('The server could not be reached. You may want to ' + - 'verify your connection and try again.')); - } else { - msg += (t('An unknown error occurred. (Status: %s )', status)); - } - } - return msg; - }, - error(msg, xhr) { - let errorMsg = msg; - token.find('img.loading').hide(); - container.fadeTo(0.5, 1); - sliceCell.removeClass('slice-cell-highlight'); - let errHtml = ''; - let o; - try { - o = JSON.parse(msg); - if (o.error) { - errorMsg = o.error; - } - } catch (e) { - // pass - } - if (errorMsg) { - errHtml += `
${errorMsg}
`; - } - if (xhr) { - if (xhr.statusText === 'timeout') { - errHtml += ( - '
' + - 'Query timeout - visualization query are set to time out ' + - `at ${timeout} seconds.
`); - } else { - const extendedMsg = this.getErrorMsg(xhr); - if (extendedMsg) { - errHtml += `
${extendedMsg}
`; - } - } - } - container.html(errHtml); - container.show(); - $('span.query').removeClass('disabled'); - $('.query-and-save button').removeAttr('disabled'); - this.always(o); - controller.error(this); - }, - clearError() { - $(selector + ' div.alert').remove(); - }, - width() { - return container.width(); - }, - height() { - let others = 0; - const widget = container.parents('.widget'); - const sliceDescription = widget.find('.slice_description'); - if (sliceDescription.is(':visible')) { - others += widget.find('.slice_description').height() + 25; - } - others += widget.find('.chart-header').height(); - return widget.height() - others - 10; - }, - bindResizeToWindowResize() { - let resizeTimer; - const slice = this; - $(window).on('resize', function () { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(function () { - slice.resize(); - }, 500); - }); - }, - render(force) { - if (force === undefined) { - this.force = false; - } else { - this.force = force; - } - const formDataExtra = Object.assign({}, formData); - formDataExtra.extra_filters = controller.effectiveExtraFilters(sliceId); - controls.find('a.exploreChart').attr('href', getExploreUrl(formDataExtra)); - controls.find('a.exportCSV').attr('href', getExploreUrl(formDataExtra, 'csv')); - token.find('img.loading').show(); - container.fadeTo(0.5, 0.25); - sliceCell.addClass('slice-cell-highlight'); - container.css('height', this.height()); - const asyncAnnotations = (formData.annotation_layers || []) - .map(x => runAnnotationQuery(x, timeout, formData)); - $.ajax({ - url: this.jsonEndpoint(formDataExtra), - timeout: timeout * 1000, - success: (queryResponse) => { - try { - this.done(queryResponse); - // render when all the annotations are available - Promise.all(asyncAnnotations) - .then((annotations) => { - this.annotationData = annotations - .reduce((data, a) => ({ ...data, ...a }), {}); - return Promise.resolve(); - }) - .then(() => vizMap[formData.viz_type](this, queryResponse)); - } catch (e) { - this.error(t('An error occurred while rendering the visualization: %s', e)); - } - }, - error: (err) => { - this.error(err.responseText, err); - }, - }); - }, - resize() { - this.render(); - }, - addFilter(col, vals, merge = true, refresh = true) { - controller.addFilter(sliceId, col, vals, merge, refresh); - }, - setFilter(col, vals, refresh = true) { - controller.setFilter(sliceId, col, vals, refresh); - }, - getFilters() { - return controller.filters[sliceId]; - }, - clearFilter() { - controller.clearFilter(sliceId); - }, - removeFilter(col, vals) { - controller.removeFilter(sliceId, col, vals); - }, - }; - return slice; - }; - // Export public functions - return { - getParam, - initFavStars, - Slice, - }; -}; -module.exports = px; From ea9dcd84b3d42c4306d5b835fa6a24b29164aa4f Mon Sep 17 00:00:00 2001 From: Fabian Date: Sat, 16 Dec 2017 15:20:10 -0500 Subject: [PATCH 8/8] Refactor slice form_data to data --- .../explore/components/controls/AnnotationLayer.jsx | 6 +++--- superset/views/core.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx index 2deac50b04baa..aa34fb04f7db1 100644 --- a/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx +++ b/superset/assets/javascripts/explore/components/controls/AnnotationLayer.jsx @@ -311,9 +311,9 @@ export default class AnnotationLayer extends React.PureComponent { timeColumn, intervalEndColumn, descriptionColumns } = this.state; const slice = (valueOptions.find(x => x.value === value) || {}).slice; if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && slice) { - const columns = (slice.form_data.groupby || []).concat( - (slice.form_data.all_columns || [])).map(x => ({ value: x, label: x })); - const timeColumnOptions = slice.form_data.include_time ? + const columns = (slice.data.groupby || []).concat( + (slice.data.all_columns || [])).map(x => ({ value: x, label: x })); + const timeColumnOptions = slice.data.include_time ? [{ value: '__timestamp', label: '__timestamp' }].concat(columns) : columns; return (
diff --git a/superset/views/core.py b/superset/views/core.py index 59106f0b86090..e83d34f70b610 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1731,7 +1731,7 @@ def user_slices(self, user_id=None): 'id': o.Slice.id, 'title': o.Slice.slice_name, 'url': o.Slice.slice_url, - 'form_data': o.Slice.form_data, + 'data': o.Slice.form_data, 'dttm': o.dttm if o.dttm else o.Slice.changed_on, 'viz_type': o.Slice.viz_type, } for o in qry.all()]